multisplit — последовательная TCP-сегментация (zapret2 / nfqws2)

Файл: lua/zapret-antidpi.lua:483 nfqws1 эквивалент: --dpi-desync=multisplit Сигнатура: function multisplit(ctx, desync)

multisplit — базовая функция TCP-сегментации в zapret2. Она берёт текущий payload (или reasm, или blob), разрезает его на несколько TCP-сегментов по заданным позициям и отправляет их последовательно (от первого к последнему). После успешной отправки выносит VERDICT_DROP, чтобы оригинальный пакет не ушёл.

Родственные функции: multidisorder (обратный порядок), fakedsplit (с фейками), fakeddisorder (фейки + обратный порядок), hostfakesplit (по hostname), tcpseg (диапазон), oob (urgent byte).

О чём заметка

multisplit — функция дурения DPI в Zapret 2 (движок nfqws2), которая режет исходящий TCP-пакет на несколько кусков, чтобы цензор не увидел в нём имя запрашиваемого сайта. Здесь разобраны все аргументы функции, маркеры позиций разреза, техника seqovl и готовые примеры команд. Общий контракт --lua-desync-функций (кто и когда их вызывает) описан в desync, общий для всех техник порядок работы — в жизненный цикл desync-функции, устройство входной таблицы desync — в структура desync и диссекта.

TL;DR

  • multisplit разрезает прикладные данные (HTTP-запрос, TLS ClientHello) на несколько TCP-сегментов, отправляет их по порядку, а перехваченный оригинал дропает. Сервер собирает поток обратно штатными средствами TCP — для него ничего не меняется.
  • Смысл приёма: DPI ищет имя сайта (Host: в HTTP, SNI в TLS) сигнатурой внутри одного пакета. Если имя разорвано границей сегментов, а DPI не пересобирает TCP-поток — сигнатура не находится.
  • Где резать, задаёт аргумент pos: либо числом («первые N байт уходят отдельным сегментом»), либо маркером, привязанным к структуре протокола (midsld — середина домена второго уровня). Маркеров можно перечислить несколько через запятую.
  • seqovl=N дополнительно приклеивает слева к первому сегменту N байт фальшивки и сдвигает sequence number назад: сервер эти байты отбросит как вышедшие за границу окна, а DPI их прочитает и может принять за начало запроса.
  • Типовой рабочий вариант для TLS: --payload=tls_client_hello --lua-desync=multisplit:pos=1,midsld. Это прямой аналог --dpi-desync=multisplit --dpi-desync-split-pos=1,midsld из первой версии zapret.
  • Если открыли заметку впервые — читайте подряд до раздела «Практические примеры»; разбор кода вынесен в конец и нужен, только если вы правите Lua-функции сами.

Оглавление


Зачем нужен multisplit

DPI анализирует TCP-поток, пытаясь собрать полный payload и найти в нём сигнатуры (hostname в HTTP, SNI в TLS). Если мы разрежем пакет на несколько TCP-сегментов, DPI может:

  1. Не собрать данные: некоторые DPI работают попакетно и не реассемблируют TCP
  2. Не найти сигнатуру: если разрез проходит через Host: или SNI, ни в одном отдельном сегменте полного hostname нет
  3. Принять фейк за реальные данные: с помощью seqovl можно замешать ложную информацию, которую DPI проглотит, а сервер — нет

Сервер при этом корректно собирает поток — TCP-стек гарантирует это через sequence numbers.

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

GET /watch HTTP/1.1\r\nHost: www.youtube.com\r\n...
                           ^^^^^^^^^^^^^^^^^ сигнатура целиком в одном пакете

С --lua-desync=multisplit:pos=midsld тот же запрос уходит двумя TCP-сегментами с разрезом посередине домена второго уровня (youtube):

сегмент 1: GET /watch HTTP/1.1\r\nHost: www.you
сегмент 2: tube.com\r\n...

Строки www.youtube.com теперь нет ни в одном пакете — она появляется только после сборки потока. DPI, который сигнатуру ищет в границах отдельного пакета, её не найдёт. Сервер соберёт запрос как обычно и ответит нормально: для него это просто данные, пришедшие двумя порциями вместо одной.

Оговорка: приём рассчитан на DPI, который не пересобирает TCP-поток. Системы, умеющие реассемблирование, разрез сам по себе не обманет — поэтому multisplit обычно комбинируют с фейковыми пакетами и seqovl, а рабочую комбинацию подбирают перебором (см. verify-strategy).

multisplit — самый простой вариант: режем и шлём по порядку. Для обратного порядка есть multidisorder, для замешивания фейковых сегментов — fakedsplit/fakeddisorder.


Быстрый старт

Минимально (дефолты: pos=2 — первые 2 байта отдельным сегментом, payload=known, dir=out):

--lua-desync=multisplit

Типовой TLS-разрез:

--payload=tls_client_hello --lua-desync=multisplit:pos=1,midsld

TLS с seqovl:

--payload=tls_client_hello --lua-desync=multisplit:pos=1:seqovl=5:seqovl_pattern=0x1603030000

HTTP с разрезом по hostname:

--payload=http_req --lua-desync=multisplit:pos=host,midsld,endhost

Откуда берутся данные для нарезки

Прежде чем что-то резать, multisplit должна выбрать, какие именно данные она режет. Это делает всего одна строка, но за ней стоит цепочка приоритетов:

local data = blob_or_def(desync, desync.arg.blob) or desync.reasm_data or desync.dis.payload

Lua-оператор or возвращает первый истинный (не nil и не false) операнд, поэтому строка читается как «сверху вниз, берём первое, что есть». Разберём три источника по порядку.

Первый приоритет — явный blob. Если в аргументах задан blob=имя, функция blob_or_def достаёт содержимое этого blob и режет именно его, полностью игнорируя реальный payload пакета. Это способ отправить произвольные данные вместо того, что реально шлёт приложение: заранее заготовленный фейковый ClientHello, модифицированный запрос, содержимое файла. Что такое blob и как их объявлять (inline-hex 0x..., @файл, именованные переменные) — см. отдельную заметку blob. Если blob= не задан, blob_or_def возвращает nil, и or идёт дальше.

Второй приоритет — реассемблированные данные. Если blob нет, но движок собрал многопакетный payload в desync.reasm_data (например, TLS ClientHello, растянувшийся на несколько TCP-сегментов), режется этот собранный буфер целиком. Благодаря этому разрез по midsld попадёт в реальную середину доменного имени, даже если само имя физически лежало во втором или третьем пакете исходного потока. Механику сборки и однократной отправки reasm разбирает раздел Поведение при replay / reasm.

Третий приоритет — payload текущего пакета. Если ни blob, ни reasm нет (обычный одиночный пакет, всё поместилось), берётся desync.dis.payload — тело именно этого перехваченного TCP-сегмента. Это дефолтный путь для большинства простых случаев.

Почему порядок именно такой и что из этого следует. Все дальнейшие аргументы — маркеры pos, длина seqovl, проверки — применяются к тем данным, которые реально выбраны на этом шаге, а не к «пакету вообще». Практическое следствие: если вы задали blob=myblob, то относительные маркеры вроде midsld или host сработают только при условии, что содержимое myblob — это валидный TLS- или HTTP-payload, который движок сумеет распознать (иначе desync.l7payload для blob окажется unknown и такие маркеры молча отвалятся, см. Важные нюансы pos).

Проще говоря

Функция сначала спрашивает: «мне явно подсунули blob? — режу его». Если нет: «есть собранный из кусков payload? — режу его». Если и его нет: «режу тело текущего пакета». Маркеры и seqovl всегда работают по тем данным, которые выиграли этот выбор.


Маркеры позиций (pos)

pos — главный аргумент multisplit. Определяет где внутри payload будет произведён разрез. Задаётся как строка со списком маркеров через запятую.

Маркер — это смещение от начала данных, считая с нуля: разрез происходит перед байтом с таким номером. Отсюда простое правило, которым удобно пользоваться на практике: pos=N означает «первые N байт уходят отдельным сегментом». pos=1 → первый сегмент длиной 1 байт, pos=5 → 5 байт, дефолтный pos=2 → 2 байта. Внутри Lua те же позиции хранятся как 1-based индексы строки (resolve_multi_pos прибавляет единицу), но в командной строке считать нужно от нуля.

Типы маркеров

ТипОписаниеПример
Абсолютный положительныйСмещение от начала payload, считая с 0. pos=N → первые N байт становятся отдельным сегментом1, 5, 100
Абсолютный отрицательныйСмещение от конца payload. -1 = последний байт (он уйдёт отдельным сегментом)-1, -10, -50
ОтносительныйЛогическая позиция внутри распознанного payload. Привязана к структуре протоколаmidsld, host, sniext

Относительные маркеры

МаркерОписаниеДля каких payload
methodНачало HTTP-метода (GET, POST, HEAD, PUT и т.д.). Обычно позиция 0, но может стать 1-2 при использовании http_methodeolhttp_req
hostПервый байт имени хоста (Host: в HTTP, SNI в TLS)http_req, tls_client_hello
endhostБайт, следующий за последним байтом имени хоста. Т.е. host..endhost-1 = полный hostnamehttp_req, tls_client_hello
sldПервый байт домена второго уровня (SLD). Для www.example.com — это e в examplehttp_req, tls_client_hello
endsldБайт, следующий за последним байтом SLD. Для example.com — это . после examplehttp_req, tls_client_hello
midsldСередина SLD (самый популярный маркер). Для example (7 символов) — позиция 3-го или 4-го символаhttp_req, tls_client_hello
sniextНачало поля данных SNI extension в TLS ClientHello. Extension состоит из type (2 байта) + length (2 байта) + данные — sniext указывает на начало данныхtls_client_hello
extlenПоле длины всех TLS extensionstls_client_hello

Арифметика маркеров

К любому маркеру можно прибавить (+) или вычесть (-) целое число:

midsld+1      — один байт ПОСЛЕ середины SLD
midsld-1      — один байт ДО середины SLD
endhost-2     — два байта до конца hostname
method+2      — два байта после начала метода (разрежет "GET " после "GE")
sniext+1      — один байт после начала SNI extension data
host+3        — три байта после начала hostname
-1            — последний байт payload (абсолютный, не относительный)

Арифметика работает и с абсолютными маркерами, хотя это избыточно (5+3 = 8).

Пример списка маркеров

pos=100,midsld,sniext+1,endhost-2,-10

Здесь 5 маркеров → payload разрежется максимум на 6 частей (если все маркеры успешно разрешатся и дадут различные позиции).

Как маркеры разрешаются в коде

Внутри multisplit вызывается:

local pos = resolve_multi_pos(data, desync.l7payload, spos)

Функция resolve_multi_pos:

  1. Разбивает строку spos по запятым
  2. Для каждого маркера вызывает resolve_pos(blob, l7payload_type, marker)
  3. Если маркер не может быть разрешён (например, midsld для unknown payload) — он молча пропускается
  4. Результаты дедуплицируются и сортируются
  5. Возвращается массив уникальных абсолютных позиций, уже переведённых в 1-based индексы Lua (маркер 1 превращается в позицию 2, маркер 0 — в позицию 1)

Затем вызывается:

delete_pos_1(pos)  -- удалить Lua-позицию 1, то есть маркер 0

Важные нюансы pos

  • Разрез в самом начале данных отбрасывается. delete_pos_1 удаляет Lua-позицию 1, которая соответствует маркеру 0. Резать «перед первым байтом» бессмысленно: первый сегмент получился бы пустым. Практическое следствие — pos=0 не сработает, а pos=1 (первый байт отдельным сегментом) работает и используется очень часто, в том числе в связке с seqovl
  • Реальный случай, когда маркер выпадает по этой причине — pos=method для HTTP. Метод (GET, POST) обычно лежит на смещении 0, поэтому маркер разрешится в позицию 1 и будет удалён; разреза не произойдёт вовсе. Чтобы резать внутри метода, нужен сдвиг: pos=method+2
  • Дублирующиеся позиции объединяются. pos=5,5,5 = pos=5
  • Неразрешимые маркеры пропускаются. Если midsld не разрешается (payload = unknown), он просто исчезает из списка. Если все маркеры не разрешились — multisplit ничего не делает (логирует “no valid split positions”)
  • Позиции сортируются. Независимо от порядка записи, pos=100,5,50 будет обработано как 5,50,100
  • По умолчанию pos=2. Если pos не задан, payload делится на 2 части: первые 2 байта отдельным сегментом, весь остаток — вторым. Тот же дефолт был у --dpi-desync-split-pos в nfqws1

seqovl — скрытый фейк внутри сегмента

seqovl (Sequence Overlap) — техника скрытого замешивания фейковых данных в реальный TCP-сегмент через манипуляцию TCP sequence number. В multisplit seqovl применяется только к первому отправляемому сегменту.

Принцип работы seqovl

Чтобы понять seqovl, нужно вспомнить, как TCP-получатель решает, какие байты принять. У каждого байта в TCP-потоке есть свой sequence number — сквозной номер позиции в потоке. Приёмная сторона держит «окно» (receive window) и принимает только байты, чей номер попадает в него, начиная с номера следующего ожидаемого байта. Всё, что левее этой границы (номера, которые сторона уже получила или ещё не ждёт с этого места), молча отбрасывается как дубликат или мусор — это штатное поведение TCP-стека, а не ошибка.

seqovl эксплуатирует ровно этот механизм. Возьмём для примера первый сегмент, который в норме начинался бы с sequence number 1000 и нёс реальные данные. Без seqovl сегмент уходит как есть: seq = 1000, тело — реальная первая часть, сервер принимает её целиком, потому что 1000 — как раз следующий ожидаемый им байт.

С seqovl=10 функция делает две вещи одновременно. Во-первых, дописывает слева к телу сегмента 10 байт паттерна-фейка (seqovl_pattern, по умолчанию нули). Во-вторых, уменьшает объявленный в заголовке sequence number на те же 10 — с 1000 до 990. В результате в сеть уходит один-единственный, совершенно обычный с виду TCP-сегмент: seq = 990, тело = [10 байт паттерна][реальная первая часть].

Дальше этот сегмент по-разному воспринимают две стороны.

Что видит DPI. Для системы глубокого анализа это цельный TCP-сегмент, начинающийся с sequence 990. Многие DPI разбирают содержимое сегмента «как есть», от его начала, не сверяясь с тем, какие именно байты примет получатель. Поэтому DPI анализирует весь блок — вместе с 10 байтами паттерна. Если в паттерн подложен, например, фрагмент, похожий на начало легитимного TLS-record или ложный SNI, DPI может принять фейк за реальные данные и вынести решение по нему, а не по настоящему домену.

Что видит сервер (TCP-стек получателя). Следующий ожидаемый им байт по-прежнему имеет номер 1000. Байты с номерами 990–999 (те самые 10 байт паттерна) оказываются левее границы окна — сервер отбрасывает их автоматически, как повтор/устаревшие данные. А байты, начиная с 1000 (реальная первая часть), попадают в окно и принимаются. В итоге сервер собирает ровно исходный поток, будто никакого паттерна и не было.

Наглядно расклад по одному sequence-байту такой:

Диапазон seqСодержимоеDPIСервер (TCP-стек)
990–999PATTERN (10 байт фейка)анализирует вместе со всем сегментомотбрасывает — левее границы окна
с 1000реальная первая частьанализируетпринимает

Ключевая идея: одни и те же 10 байт для DPI — «полноценные данные для анализа», а для сервера — «мусор за левым краем окна». Расхождение в трактовке и есть рабочий механизм обхода.

Зачем seqovl лучше обычного fooling

КритерийОбычный fooling (TTL, badseq, md5sig)seqovl
ЗаголовкиМодифицируются (TTL, seq, ack, md5)Не модифицируются — пакет выглядит полностью легитимным
ОбнаружениеDPI может детектировать подозрительные заголовкиDPI видит “честный” сегмент с правильными заголовками
Механизм отбрасыванияСервер отбрасывает весь пакет из-за невалидных заголовковСервер отбрасывает только часть, выходящую за TCP window
НадёжностьЗависит от поведения конкретного стекаОснован на фундаментальном свойстве TCP

Вывод: seqovl — средство создания скрытых фейков, не требующее fooling. Это его ключевое преимущество.

seqovl_pattern

Паттерн, которым заполняется seqovl-область (N байт слева от реальных данных). По умолчанию — 0x00 (нули).

В multisplit seqovl_pattern — это имя blob. Паттерн повторяется до нужной длины seqovl.

# Inline hex blob (маскировка под начало TLS record)
--lua-desync=multisplit:pos=1:seqovl=5:seqovl_pattern=0x1603030000
 
# Предзагруженный blob
--blob=tlspat:0x1603030100 \
--lua-desync=multisplit:pos=1:seqovl=8:seqovl_pattern=tlspat

Если optional задан и blob seqovl_pattern отсутствует — используется нулевой паттерн (операция не отменяется).

Важно в multisplit: seqovl — только число, маркеры не поддерживаются (в отличие от multidisorder и fakeddisorder, где seqovl может быть маркером).


Полный список аргументов

Формат вызова:

--lua-desync=multisplit[:arg1[=val1][:arg2[=val2]]...]

Все val приходят в Lua как строки. Если =val не указан, значение = пустая строка "" (в Lua это truthy), поэтому флаги пишутся просто как :optional, :nodrop, :tcp_ts_up.

A) Собственные аргументы multisplit

pos

  • Формат: pos=<marker[,marker2,...]>
  • Тип: строка со списком маркеров через запятую
  • По умолчанию: "2"
  • Описание: Точки разреза, смещения считаются от начала данных с нуля. N маркеров → до N+1 сегментов
  • Примеры:
    • pos=2 — разрез после 2-го байта (дефолт)
    • pos=1 — первый байт уходит отдельным сегментом
    • pos=midsld — разрез посередине SLD
    • pos=1,midsld — два разреза: после 1-го байта и посередине SLD → 3 сегмента
    • pos=host,midsld,endhost-2,-10 — четыре разреза → до 5 сегментов
    • pos=method+2 — после первых 2 символов HTTP-метода (сам pos=method не сработает, см. Важные нюансы pos)

seqovl

  • Формат: seqovl=N (где N > 0)
  • Тип: только число (маркеры не поддерживаются — в отличие от multidisorder)
  • По умолчанию: не задан (нет seqovl)
  • Описание: Применяется только к первому отправляемому сегменту. К данным первого сегмента слева добавляется N байт seqovl_pattern, а TCP th_seq уменьшается на N. Сервер отбросит левую часть, DPI — может не отбросить
  • Примеры:
    • seqovl=5 — 5 байт фейка слева
    • seqovl=13 — 13 байт фейка слева
    • seqovl=10000 — 10000 байт (если превысит MSS — автосегментация разобьёт на несколько TCP-сегментов)

seqovl_pattern

  • Формат: seqovl_pattern=<blobName>
  • Тип: имя blob-переменной
  • По умолчанию: один байт 0x00, повторяемый до длины seqovl
  • Описание: Данные для заполнения seqovl-области. Blob повторяется функцией pattern() до нужного размера
  • Поведение с optional: если optional задан и blob отсутствует — используется нулевой паттерн, seqovl не отменяется
  • Примеры:
    • seqovl_pattern=0x1603030000 — inline hex (маскировка под TLS)
    • seqovl_pattern=my_pattern_blob — предзагруженный blob

blob

  • Формат: blob=<blobName>
  • Тип: имя blob-переменной
  • По умолчанию: не задан
  • Описание: Заменить текущий payload/reasm на указанный blob и резать/слать его. Используется для отправки произвольных данных (фейковых payload, модифицированных ClientHello и т.д.)
  • Примеры:
    • blob=fake_default_tls — стандартный TLS-фейк
    • blob=0xDEADBEEF — inline hex
    • blob=my_custom_ch — предзагруженный blob

optional

  • Формат: optional (флаг, без значения)
  • Описание: Мягкий режим:
    • Если задан blob=... и blob отсутствует → multisplit ничего не делает (тихий skip, без ошибок)
    • Если задан seqovl_pattern=... и blob отсутствует → используется нулевой паттерн (seqovl не отменяется)
  • Использование: защита от ошибок при использовании blob, которые могут отсутствовать (например, если blob генерируется другой функцией)

nodrop

  • Формат: nodrop (флаг, без значения)
  • Описание: После успешной отправки сегментов не выносить VERDICT_DROP (вместо этого вернуть VERDICT_PASS). Это означает, что оригинальный пакет тоже будет отправлен (наряду с нарезанными сегментами)
  • Использование: для отладки, для отправки произвольных данных без блокировки оригинала
  • Предупреждение: в боевых профилях nodrop обычно нежелателен — оригинал ещё раз уйдёт, что создаст дублирование и может ухудшить обход

B) Standard direction

ПараметрЗначенияПо умолчанию
dirin, out, anyout

Фильтр по направлению пакета. multisplit по умолчанию работает только с исходящими (out).

  • dir=out — только исходящие (от клиента к серверу)
  • dir=in — только входящие (от сервера к клиенту)
  • dir=any — оба направления

При первом вызове с указанным dir функция делает direction_cutoff_opposite — отсекает себя от противоположного направления.


C) Standard payload

ПараметрЗначенияПо умолчанию
payloadсписок типов через запятуюknown

Фильтр по типу payload на уровне Lua. Это дополнительный фильтр к --payload=... на уровне профиля.

  • payload=known — только распознанные протоколы (http_req, tls_client_hello, quic_initial и т.д.)
  • payload=all — любой payload, включая unknown
  • payload=tls_client_hello,http_req — конкретные типы
  • payload=~unknown — инверсия: всё кроме unknown

Важно: лучше ставить --payload=... на уровне профиля (C-код, быстрее), а не полагаться только на Lua-фильтр.


D) Standard fooling

Модификации L3/L4 заголовков. В multisplit применяются ко всем отправляемым сегментам (в отличие от fakedsplit, где fooling идёт только на фейки).

ПараметрОписаниеПример
ip_ttl=NУстановить IPv4 TTLip_ttl=6
ip6_ttl=NУстановить IPv6 Hop Limitip6_ttl=6
ip_autottl=delta,min-maxАвтоматический TTL (delta от серверного TTL)ip_autottl=-2,40-64
ip6_autottl=delta,min-maxАналогично для IPv6ip6_autottl=-2,40-64
ip6_hopbyhop[=HEX]Вставить extension header hop-by-hop (по умолчанию 6 нулей)ip6_hopbyhop
ip6_hopbyhop2[=HEX]Второй hop-by-hop headerip6_hopbyhop2
ip6_destopt[=HEX]Destination options headerip6_destopt
ip6_destopt2[=HEX]Второй destination optionsip6_destopt2
ip6_routing[=HEX]Routing headerip6_routing
ip6_ah[=HEX]Authentication headerip6_ah
tcp_seq=NСместить TCP sequence (+ или -)tcp_seq=-10000
tcp_ack=NСместить TCP ack (+ или -)tcp_ack=-66000
tcp_ts=NСместить TCP timestamptcp_ts=-100
tcp_md5[=HEX]Добавить TCP MD5 option (16 байт; по умолчанию случайные)tcp_md5
tcp_flags_set=LISTУстановить TCP-флагиtcp_flags_set=FIN,PUSH
tcp_flags_unset=LISTСнять TCP-флагиtcp_flags_unset=ACK
tcp_ts_upПоднять TCP timestamp option в начало заголовкаtcp_ts_up
tcp_nop_delУдалить все TCP NOP опцииtcp_nop_del
fool=<func>Кастомная Lua-функция foolingfool=my_fooler

Заметка про tcp_ts_up: На Linux-серверах пакеты с инвалидным ACK стабильно отбрасываются только если TCP timestamp option идёт первой в заголовке. tcp_ts_up перемещает её в начало, обеспечивая корректную работу badseq-fooling.


E) Standard ipid

ПараметрОписаниеПо умолчанию
ip_id=seqПоследовательные IP IDseq
ip_id=rndСлучайные IP ID
ip_id=zeroНулевые IP ID
ip_id=noneНе менять IP ID
ip_id_connСквозная нумерация IP ID в рамках соединения (требует tracking)

ip_id применяется к каждому отправляемому сегменту (включая под-сегменты при MSS-сегментации).


F) Standard ipfrag

IP-фрагментация поверх TCP-сегментации. Каждый TCP-сегмент дополнительно фрагментируется на уровне IP.

ПараметрОписаниеПо умолчанию
ipfrag[=func]Включить IP-фрагментацию. Если без значения → ipfrag2
ipfrag_disorderОтправить IP-фрагменты в обратном порядке
ipfrag_pos_tcp=NПозиция фрагментации TCP (кратно 8)32
ipfrag_pos_udp=NПозиция фрагментации UDP (кратно 8). Для multisplit бесполезно — он только TCP8
ipfrag_next=NIPv6: next protocol во 2-м фрагменте (penetration атака на фаерволы)

G) Standard reconstruct

ПараметрОписание
badsumИспортить L4 (TCP) checksum при реконструкции raw-пакета. Сервер отбросит такой пакет

H) Standard rawsend

ПараметрОписание
repeats=NОтправить каждый сегмент N раз (идентичные повторы)
ifout=<iface>Интерфейс для отправки (по умолчанию определяется автоматически)
fwmark=NFirewall mark (только Linux, nftables/iptables)

Порядок отправки сегментов

multisplit всегда отправляет сегменты последовательно — от первого к последнему (в порядке возрастания TCP sequence).

Пример с 3 позициями разреза

Возьмём payload длиной 600 байт и три позиции разреза: pos=100,300,450. Они делят данные на четыре куска (0–99, 100–299, 300–449, 450–599), и multisplit отправляет их четырьмя TCP-сегментами по возрастанию sequence number. Sequence каждого сегмента равен смещению его первого байта в исходном payload, поэтому сервер собирает поток обратно без пропусков:

СегментБайты payloadДлинаСмещение (seq)
10–991000
2100–299200100
3300–449150300
4450–599150450

Пример с seqovl=10

Тот же payload 600 байт, но pos=100 (один разрез, два сегмента) и seqovl=10. К первому сегменту слева дописываются 10 байт паттерна, а его sequence сдвигается на те же 10 в минус — поэтому паттерн уходит за левую границу окна и сервер его отбрасывает, приняв только реальную часть с seq 0:

СегментСодержимоеДлинаСмещение (seq)Что с ним на сервере
110 байт паттерна + байты 0–99110−10паттерн отброшен, реальная часть принята
2байты 100–599500100принят целиком

Поведение при replay / reasm

Многопакетный запрос (например, большой TLS ClientHello с post-quantum ключами, не влезающий в один TCP-сегмент) nfqws2 придерживает, собирает в буфер reasm_data и затем перепроигрывает через desync-функции. multisplit использует штатную развилку перепроигрывания из общего скелета desync-функций (жизненный цикл desync-функции, стадия 6) без изменений: режет и отправляет весь собранный буфер на первой части, а остальные части дропает, потому что их содержимое уже ушло в составе нарезки.

Специфика именно multisplit тут одна: маркеры разрешаются по всему reasm_data, а не по отдельному пакету. Поэтому разрез по midsld попадёт в реальную середину домена, даже если имя хоста физически лежало во втором или третьем пакете исходного потока.

Если отправка сорвалась (rawsend вернул false), флаг «реасм уже отправлен» не ставится и оставшиеся части проходят как есть — данные не теряются.


Автосегментация по MSS

О размерах TCP-сегментов думать не нужно. Отправкой занимается rawsend_payload_segmented из zapret-lib.lua, и она сама доводит куски до допустимого размера:

  1. MSS берётся из desync.tcp_mss — движок отслеживает его для каждого TCP-соединения
  2. Если кусок вместе с заголовками превышает MSS — он дополнительно режется по MSS
  3. Каждый под-сегмент отправляется с корректным TCP sequence (th_seq увеличивается на длину предыдущего)

Есть тонкость с тем, что применяется к под-сегментам, а что нет: fooling применяется один раз к исходному диссекту, то есть достаётся всем под-сегментам одинаково, а политика ip_id применяется отдельно к каждому под-сегменту.

Пример: seqovl=10000 не вызовет ошибку. Функция отправит несколько TCP-сегментов подряд, начиная со сдвига −10000 относительно текущего sequence: сначала пойдут полные сегменты паттерна, а в последнем к остатку паттерна будет пристыковано начало реальных данных.


Нюансы и подводные камни

1. Работает только с TCP — и почему

multisplit обрабатывает исключительно TCP. Первая же стадия общего скелета отсеивает всё остальное: на не-TCP пакете функция делает instance_cutoff и навсегда отключается от этого потока, чтобы не тратить CPU (исключение — связанный ICMP; механика описана в жизненный цикл desync-функции).

Ограничение не техническая недоработка, а следствие сути приёма — он опирается на два свойства, которые есть у TCP и отсутствуют у UDP.

TCP — это поток байтов со сквозной нумерацией, UDP — набор независимых датаграмм. В TCP у каждого байта есть свой sequence number, и получатель обязан по этим номерам собрать поток в исходном порядке — это гарантия протокола. Поэтому один payload можно разрезать на несколько сегментов, разослать их (по порядку — multisplit, вперемешку — multidisorder), и сервер прозрачно склеит всё обратно: ему безразлично, пришли данные одним пакетом или пятью. В UDP такого нет — каждая датаграмма самодостаточна, никакой пересборки «потока» по номерам байт на приёмной стороне не происходит. Разрезать UDP-payload на две датаграммы бессмысленно: принимающее приложение получит два отдельных куска и не обязано (и не будет) склеивать их так, как задумано. Резать в TCP-стиле там просто нечего.

seqovl тем более завязан на TCP. Трюк seqovl держится на конкретной детали TCP-стека: получатель отбрасывает байты, чей sequence number ушёл левее границы окна приёма. Дописываем фейк слева, сдвигаем seq в минус — сервер выкидывает фейк, а DPI его анализирует. У UDP нет ни sequence number, ни окна приёма, ни понятия «байты левее границы» — эксплуатировать нечего.

Что делают с UDP вместо этого. UDP-протоколы (в первую очередь QUIC) обходят другими desync-функциями: fake (фейковая датаграмма перед реальной), IP-фрагментация ipfrag (режется на уровне IP, а не TCP), манипуляции с длиной и прочее. То есть UDP работают на других уровнях, а TCP-сегментация как класс к нему неприменима. Обзор всех функций дурения — в desync.

Проще говоря

multisplit режет один payload на куски и рассчитывает, что получатель склеит их обратно «по номерам». Такое склеивание умеет только TCP. UDP обрабатывает каждый пакет сам по себе и склеивать по номерам не умеет — значит, и резать в этом стиле нечего.

2. Разрез в самом начале данных отбрасывается

delete_pos_1(pos) убирает из списка Lua-позицию 1 — она соответствует маркеру 0, то есть разрезу перед первым байтом, от которого первый сегмент вышел бы пустым. Если после этой чистки не осталось ни одной позиции, multisplit не делает ничего и пакет уходит целым.

Путаться тут легко из-за разной нумерации: в командной строке маркеры считаются от нуля, внутри Lua — от единицы. Поэтому под удаление попадает не pos=1, а pos=0. Самая частая реальная жертва — pos=method для HTTP: метод обычно лежит на смещении 0, маркер разрешается в удаляемую позицию, и разреза не происходит. Рабочий вариант — pos=method+2.

3. Все маркеры могут не разрешиться

Если вы указали pos=midsld,sniext для HTTP-payload, оба маркера (специфичные для TLS) не разрешатся. Multisplit напишет в лог “no valid split positions” и ничего не сделает.

4. nodrop создаёт дублирование

С nodrop multisplit отправляет нарезанные сегменты И пропускает оригинальный пакет. Сервер получит данные дважды. Используйте nodrop только для отладки или когда это осознанно нужно.

5. seqovl=10000 не вызовет ошибку

В отличие от nfqws1, где большие значения seqovl вызывали ошибку, nfqws2 автоматически сегментирует по MSS. Большой seqovl просто создаст много под-сегментов.

6. Fooling применяется ко ВСЕМ сегментам

В multisplit все сегменты — а других, кроме реальных, функция и не шлёт — получают fooling целиком. У fakedsplit и fakeddisorder иначе: там fooling в полном объёме достаётся только фейкам, а к реальным частям применяется единственная опция tcp_ts_up.

Практическое следствие: портящие пакет опции в multisplit вредны. Если задать tcp_ack=-66000, все сегменты получат инвалидный ack, сервер их отбросит — и соединение просто не состоится. Осмысленны здесь лишь те опции, которые не мешают серверу принять данные: tcp_ts_up, политика ip_id, IPv6 extension headers. Подробнее про сами опции — в ts-and-fooling.

7. Порядок инстансов важен

Если перед multisplit стоит инстанс, модифицирующий диссект (например pktmod), то multisplit порежет уже изменённые данные — порядок --lua-desync в профиле определяет порядок применения. Разбор того, как аргументы и инстансы выстраиваются в цепочку, — в последовательность аргументов и desync.

Частое заблуждение: VERDICT_DROP якобы обрывает цепочку. Это не так. Движок проходит все инстансы профиля и агрегирует их вердикты (VERDICT_MODIFY перебивает VERDICT_PASS, VERDICT_DROP перебивает оба). Инстансы, стоящие после multisplit, будут вызваны как обычно и увидят тот же самый диссект; дроп означает лишь, что оригинальный пакет в итоге не будет отправлен. Оборвать выполнение может только явный вызов execution_plan_cancel, которого в multisplit нет.


Отличия от других функций сегментации

Аспектmultisplitmultidisorderfakedsplitfakeddisorder
Количество позицийСписок (любое кол-во)Список (любое кол-во)ОднаОдна
Порядок отправкиПрямой (1→2→3)Обратный (3→2→1)ПрямойОбратный
Фейковые сегментыНетНетДа (до 4 шт.)Да (до 4 шт.)
seqovl типТолько числоМаркерТолько числоМаркер
seqovl к какому сегменту1-й2-й (предпоследний)1-й реальный2-й реальный
Fooling кВсем сегментамВсем сегментамФейкам полностью, реальным частям — только tcp_ts_upФейкам полностью, реальным частям — только tcp_ts_up
ipfragДаДаНетНет

Миграция с nfqws1

Соответствие параметров

nfqws1nfqws2
--dpi-desync=multisplit--lua-desync=multisplit
--dpi-desync-split-pos=midsld:pos=midsld
--dpi-desync-split-pos=1,midsld:pos=1,midsld
--dpi-desync-split-seqovl=5:seqovl=5
--dpi-desync-split-seqovl-pattern=0x1603030000:seqovl_pattern=0x1603030000
--dpi-desync-any-protocolНе нужно; или payload=all в инстансе

Пример полной миграции

# nfqws1:
nfqws --dpi-desync=fake,multisplit \
  --dpi-desync-fooling=md5sig \
  --dpi-desync-split-pos=1,midsld \
  --dpi-desync-split-seqovl=5 \
  --dpi-desync-split-seqovl-pattern=0x1603030000 \
  --dpi-desync-fake-tls-mod=rnd,rndsni,dupsid
 
# nfqws2 (эквивалент):
nfqws2 \
  --payload=tls_client_hello \
    --lua-desync=fake:blob=fake_default_tls:tcp_md5:tls_mod=rnd,rndsni,dupsid \
  --payload=http_req \
    --lua-desync=fake:blob=fake_default_http:tcp_md5 \
  --payload=tls_client_hello,http_req \
    --lua-desync=multisplit:pos=1,midsld:seqovl=5:seqovl_pattern=0x1603030000
# nfqws1:
nfqws --dpi-desync=syndata,multisplit --dpi-desync-split-pos=midsld --wssize 1:6
 
# nfqws2 (порядок инстансов важен!):
nfqws2 \
  --lua-desync=wssize:wsize=1:scale=6 \
  --lua-desync=syndata \
  --lua-desync=multisplit:pos=midsld

Практические примеры

Минимальный (дефолт: pos=2, dir=out, payload=known)

--lua-desync=multisplit

Разрезает payload после 2-го байта → 2 сегмента: первые 2 байта и весь остаток.

HTTP: разрез после метода

--payload=http_req --lua-desync=multisplit:pos=method+2

Для GET /path... разрежет после GE → DPI не увидит полный метод.

HTTP: несколько разрезов вокруг hostname

--payload=http_req --lua-desync=multisplit:pos=host,midsld,endhost

Разрезает: до hostname | первая половина | вторая половина | после hostname → 4 сегмента.

TLS: разрез посередине SNI

--payload=tls_client_hello --lua-desync=multisplit:pos=midsld

SNI разрезан пополам — ни в одном сегменте нет полного домена.

TLS: два разреза + seqovl

--payload=tls_client_hello --lua-desync=multisplit:pos=1,midsld:seqovl=5:seqovl_pattern=0x1603030000

3 сегмента: первый с 5-байтовым TLS-фейком слева (DPI может принять за начало TLS record).

Произвольный blob вместо payload

--blob=mydata:@custom_payload.bin \
--lua-desync=multisplit:blob=mydata:pos=10,100,-20

Режет и отправляет произвольные данные из файла вместо реального payload.

Защита от отсутствующего blob

--lua-desync=multisplit:blob=maybe_missing:optional:pos=2

Если blob не существует — тихий пропуск, без ошибок и без VERDICT_DROP.

Отладка: не блокировать оригинал

--payload=http_req --lua-desync=multisplit:pos=method+2:nodrop

Отправляет нарезанные сегменты И пропускает оригинальный пакет (для экспериментов).

С TCP timestamp + IP ID

--payload=tls_client_hello --lua-desync=multisplit:pos=1:tcp_ts_up:ip_id=seq:ip_id_conn

Повторы отправки

--payload=tls_client_hello --lua-desync=multisplit:pos=1:repeats=2

Каждый сегмент отправляется 2 раза. Повторы бинарно идентичны — repeats не трогает содержимое пакета, в том числе ip_id. Если нужны разные ip_id, repeats не подойдёт.

IP-фрагментация поверх TCP-сегментации

--payload=tls_client_hello --lua-desync=multisplit:pos=1,midsld:ipfrag:ipfrag_disorder:ipfrag_pos_tcp=32

Каждый TCP-сегмент дополнительно фрагментируется на IP-уровне в обратном порядке.

Комбинация: fake → multisplit

--payload=tls_client_hello \
  --lua-desync=fake:blob=fake_default_tls:tcp_md5:tls_mod=rnd,rndsni,dupsid \
  --lua-desync=multisplit:pos=1,midsld:seqovl=5:seqovl_pattern=0x1603030000

Сначала отправляется фейковый TLS ClientHello (с fooling), затем реальный — нарезанный на 3 сегмента с seqovl.

Боевой пример для YouTube

--filter-tcp=443 --hostlist=youtube.txt \
  --lua-desync=fake:blob=fake_default_tls:repeats=11:tcp_md5 \
  --lua-desync=multisplit:pos=1,midsld

11 фейков подряд + реальный payload разрезан на 3 части.


Как это устроено внутри

Дальше идёт разбор кода функции. Для настройки обхода он не нужен — практическая часть закончилась выше. Читать имеет смысл, если вы правите Lua-функции zapret2 или пишете свои.

Место в общем скелете

Тело multisplit построено по тому же шаблону, что и все остальные функции дурения: восемь стадий от отсева чужого транспорта до вердикта. Разобраны они один раз в жизненный цикл desync-функции — здесь только отклонения:

Стадия общего скелетаЧто делает multisplit
1. Отсев транспортатолько TCP; на не-TCP делает instance_cutoff (кроме связанного ICMP)
2. Направлениеdir=out по умолчанию, отключается от входящего
3. Аргументыобязательных нет; optional + отсутствующий blob → тихий выход
4. Данныештатная цепочка blob → reasm → payload
5. Гвардыштатные #data>0, direction_check, payload_check (по умолчанию known)
6. replayштатная развилка: нарезка на первой части, дроп остальных
7. Своя техникаразрешение списка маркеров, нарезка на части, seqovl на первом сегменте, отправка по возрастанию sequence — см. ниже
8. ВердиктVERDICT_DROP после успешной отправки, VERDICT_PASS при nodrop или сбое rawsend

Из общего скелета полезно помнить две вещи, о которых спотыкаются чаще всего. Первая: работу функция делает не через return, а вызовами rawsend_payload_segmented — к моменту возврата сегменты уже улетели в сеть, а возвращаемое значение решает судьбу только перехваченного оригинала. Вторая: VERDICT_DROP не обрывает цепочку инстансов, вердикты всех инстансов профиля агрегируются (см. нюанс 7).

Стадия 7: нарезка и отправка

Ниже — код собственно техники (lua/zapret-antidpi.lua:483). Всё, что до него, — общий скелет.

Разрешение позиций разреза. Строку маркеров (desync.arg.pos или дефолт "2") превращаем в массив числовых позиций, затем убираем Lua-позицию 1 — она соответствует маркеру 0, разрезу перед первым байтом:

local pos = resolve_multi_pos(data, desync.l7payload, spos)
delete_pos_1(pos) -- cannot split at the first byte

Цикл нарезки и отправки. Если после чистки осталась хоть одна позиция, идём по частям. Для части i начало — pos[i] (для нулевой части — 1), конец — pos[i+1]-1 (для последней — весь остаток #data). Вырезаем кусок, для первой части (i==0) при заданном seqovl дописываем слева паттерн-фейк и отправляем сегмент со сдвинутым sequence number:

for i=0,#pos do
    local pos_start = pos[i] or 1
    local pos_end = i<#pos and pos[i+1]-1 or #data
    local part = string.sub(data,pos_start,pos_end)
    local seqovl=0
    if i==0 and desync.arg.seqovl and tonumber(desync.arg.seqovl)>0 then
        seqovl = tonumber(desync.arg.seqovl)
        local pat="\x00"
        if desync.arg.seqovl_pattern then
            -- ... взять blob или нулевой паттерн ...
            pat = blob(desync,desync.arg.seqovl_pattern)
        end
        part = pattern(pat,1,seqovl)..part
    end
    if not rawsend_payload_segmented(desync,part,pos_start-1-seqovl) then
        return VERDICT_PASS
    end
end

Третий аргумент rawsend_payload_segmented — это сдвиг sequence number: pos_start-1 переводит 1-based позицию Lua обратно в 0-based смещение байта в payload (то есть даёт правильный seq для сегмента), а вычитание seqovl уводит первый сегмент влево на длину фейка. Если rawsend вернул false — цикл прерывается и оригинал пропускается (VERDICT_PASS), чтобы данные не потерялись.

После успешной отправки всех частей ставится флаг «реасм уже отправлен» и выносится вердикт — это снова общий скелет:

replay_drop_set(desync)
return desync.arg.nodrop and VERDICT_PASS or VERDICT_DROP

Псевдокод алгоритма

Тело функции целиком, включая общие для всех техник стадии — они пронумерованы так же, как в жизненный цикл desync-функции:

function multisplit(ctx, desync)
    -- 1. Проверка: только TCP
    if not desync.dis.tcp then cutoff; return end
 
    -- 2. Cutoff противоположного направления
    direction_cutoff_opposite(ctx, desync)
 
    -- 3. Проверка optional blob
    if optional and blob specified and blob not exists then return end
 
    -- 4. Выбор данных
    data = blob_or_def(blob) or reasm_data or dis.payload
 
    -- 5. Проверки: данные не пусты, направление OK, payload OK
    if #data > 0 and direction_check() and payload_check() then
 
        -- 6. Только первый replay
        if replay_first() then
 
            -- 7. Разрешение маркеров
            pos = resolve_multi_pos(data, l7payload, pos_arg or "2")
            delete_pos_1(pos)  -- удалить Lua-позицию 1 (маркер 0)
 
            if #pos > 0 then
                -- 8. Цикл по частям (i=0 до #pos)
                for i = 0, #pos do
                    pos_start = pos[i] or 1
                    pos_end   = (i < #pos) and pos[i+1]-1 or #data
                    part      = data:sub(pos_start, pos_end)
 
                    -- 9. seqovl для первого сегмента
                    seqovl = 0
                    if i == 0 and arg.seqovl > 0 then
                        seqovl = tonumber(arg.seqovl)
                        pat = seqovl_pattern_blob or "\x00"
                        part = pattern(pat, 1, seqovl) .. part
                    end
 
                    -- 10. Отправка с автосегментацией
                    rawsend_payload_segmented(part, pos_start - 1 - seqovl)
                end
 
                -- 11. Пометить как отправленное
                replay_drop_set()
                return nodrop and VERDICT_PASS or VERDICT_DROP
            end
        else
            -- 12. Не первый replay — дропнуть если ранее успешно отправлено
            if replay_drop() then
                return nodrop and VERDICT_PASS or VERDICT_DROP
            end
        end
    end
end

📚 См. также

  • desync — обзор всех функций --lua-desync и общий контракт: кто вызывает функцию, что ей передаёт и как складываются вердикты
  • жизненный цикл desync-функции — восемь стадий, общих для всех техник дурения: отсев транспорта, выбор данных, replay, вердикты
  • структура desync и диссекта — подробное устройство таблицы desync и диссекта пакета
  • multidisorder — тот же разрез, но сегменты уходят в обратном порядке; там seqovl работает принципиально иначе
  • multidisorder_legacy — вариант multidisorder, полностью совместимый с алгоритмом nfqws1
  • fakedsplit · fakeddisorder — разрез по одной позиции с подмешиванием фейковых сегментов
  • tcpseg — отправка произвольного диапазона данных, ограниченного двумя маркерами
  • oob — сегментация с urgent-байтом вместо разреза
  • blob — как объявлять и передавать данные для аргументов blob и seqovl_pattern
  • payload — распознавание типов протоколов, от которого зависит работа относительных маркеров
  • ts-and-fooling — опции fooling и почему в multisplit большинство из них вредны
  • последовательность аргументов — как выстраивается цепочка инстансов в профиле
  • profile · preset — где --lua-desync живёт среди остальных настроек

Источники: lua/zapret-antidpi.lua:483-539 (тело multisplit), lua/zapret-lib.lua (blob_or_def, pattern, delete_pos_1, rawsend_payload_segmented, direction_check, replay_first/replay_drop), nfq2/lua.c и nfq2/protocol.c (resolve_pos/resolve_multi_pos/ResolveMultiPos), docs/manual.md, docs/readme.md из репозитория zapret2.


🤖 Эти статьи открыты — можно обучать на них ИИ

При желании вы можете натренировать ИИ на наших статьях. Исходное форматирование и скачивание всего репозитория одним zip-архивом доступны в Forgejo: исходник этой заметки · весь репозиторий.