Привет, Хабр!
Мой сервер, как и любой другой регулярно получает свою порцию фонового мусора. Сканеры, переборщики .env, искатели чужих веб-шеллов, вялые флуды. Обычно на это не смотришь: защита ест, логи скучные. Но в этот раз первая же строчка лога не сошлась сама с собой, и разбор в итоге вывел не на «очередной кривой флудер», а на структурный сдвиг, который прямо сейчас тихо ломает целый класс сетевого софта — от middlebox'ов до анализаторов трафика.
Лог: SNI есть у всех, кроме этих
Фронтенд на 443 у меня работает в TCP-режиме и маршрутизирует по SNI: смотрит имя из TLS-хендшейка и по нему выбирает бэкенд. Нет валидного SNI — некуда отправлять, соединение уходит в <NOSRV>. Побочно это работает как фильтр: всё, что стучится на голый IP без домена, отваливается само.
Формат лога я расширил, чтобы видеть и тип хендшейка, и SNI даже у отклонённых соединений:
Нормальный трафик выглядит так:
198.51.100.20:53114 [.../...] client_hello:yes sni:example.com backend:website server:local conn_cur:1 conn_rate:1 198.51.100.20:53120 [.../...] client_hello:yes sni:git.example.com backend:git server:local conn_cur:1 conn_rate:1
А атакующий — так, сотнями:
203.0.113.90:54100 [.../...] client_hello:no sni:- backend:tcp_in_443 server:<NOSRV> conn_cur:1 conn_rate:1 203.0.113.90:54101 [.../...] client_hello:no sni:- backend:tcp_in_443 server:<NOSRV> conn_cur:1 conn_rate:2 203.0.113.90:54102 [.../...] client_hello:no sni:- backend:tcp_in_443 server:<NOSRV> conn_cur:1 conn_rate:3
Тут первая странность, и она двойная. Мало того что sni:-, так ещё и client_hello:no. То есть фронтенд не то что имя не нашёл, он вообще не смог подтвердить, что перед ним ClientHello. Для трафика на 443, который сам же лезет как TLS, это звучит абсурдно: пришёл как будто TLS, а хендшейк не распознаётся. И так у каждого соединения, все сто процентов, без единого исключения. Не «иногда потерялось на гонке» — стабильно, у всех, будто по расписанию.
Придержите это client_hello:no в голове, а пока моя первая версия была самая очевидная: slowloris. Соединения шли всплесками и висели, открывались, почти ничего не слали и болтались под минуту, пока их не прибивал таймаут. Классический почерк удержания слотов. Я на этой мысли почти успокоился, зря.
Zeek: состояние S3 и мёртвая тишина в ответ
Смотрю те же соединения в conn.log. Все до единого — в одном состоянии:
ts uid orig_h orig_p resp_h resp_p proto service duration orig_bytes resp_bytes conn_state history orig_pkts resp_pkts ... ... 203.0.113.90 54100 <server> 443 tcp - 78.997213 1348 0 S3 ShADaf 3 12
Ключевое здесь:
conn_state = S3у всех 1187 соединений. S3 — соединение установлено, инициатор начал слать данные, но нормального завершения так и не случилось.resp_bytes = 0. Сервер в ответ не отдал ни байта полезной нагрузки.orig_bytes = 1348— и это число повторяется от соединения к соединению, байт в байт, во всех случаях без разброса.duration ≈ 79 c, и таких — почти все: средняя длительность по выборке вышла 82.9 секунды, дольше минуты — 1180 из 1187.
И вот здесь версия про slowloris начинает разваливаться. При slowloris смысл атаки — удерживать серверные ресурсы неполным, но продолжающимся протоколом прикладного уровня. Здесь картина другая: клиент стабильно передаёт один и тот же объём данных, после чего ничего больше не присылает. Сервер не отвечает полезными данными и закрывает соединение по таймауту. Это больше похоже не на медленное поддержание диалога, а на систематически оборванный хендшейк.
И размер, повторяющийся с точностью до байта во всех соединениях, это не поведение живого клиента. У настоящего клиента от хендшейка к хендшейку меняется хоть что-то. Здесь будто записали один пакет и гоняют по кругу.
Suricata: это TLS, но алертов ноль
Проверяю в eve.json. Все потоки классифицированы одинаково:
event_type: flow app_proto: tls flow.state: established pkts_toserver: 3 bytes_toserver: 1558 pkts_toclient: 12 bytes_toclient: 800 alert: (нет)
Suricata уверенно говорит app_proto: tls — её определитель протокола ставит это, только когда видит валидное начало TLS-записи с ClientHello. Но это не означает, что она успешно разобрала весь ClientHello или извлекла из него SNI: классификация протокола и полноценный разбор хендшейка это разные стадии обработки. При этом ни одного сигнатурного алерта: это не эксплойт, не известный мальварь, просто аномальные соединения. И снова та же картина: ровно 3 пакета к серверу, ровно 1558 байт (с заголовками) идентично во всех 1187 потоках.
Итак, три инструмента дают три кусочка, которые пока не складываются: HAProxy не видит ни ClientHello, ни SNI, Zeek видит установленное, но брошенное соединение с нулевым ответом сервера, Suricata уверена, что это TLS.
tshark: а TLS-то и нет
Ловлю двадцать пакетов в pcap и открываю. И вот тут становится по-настоящему интересно.
tshark -r ch.pcap -T fields -e ip.src -e tcp.srcport -e tls.handshake.type \ -e tls.handshake.extensions_server_name 203.0.113.90 54100 203.0.113.90 54100 203.0.113.90 54100 203.0.113.90 54101 ...
Колонка tls.handshake.type — пустая у всех. Фильтр tls.handshake.type == 1 (ClientHello) не возвращает ни строки. tls.handshake.extensions_server_name — пусто. Эталонный разборщик протоколов не видит здесь TLS вообще. С его точки зрения это чистый TCP с какими-то данными.
Это ломает голову, потому что в hex-дампе TLS виден невооружённым глазом. Байт 16 — TLS-запись, handshake. Дальше 01 — тип ClientHello. И на фоне логов с прочерком в SNI — внутри пакета открытым текстом лежит имя домена. То самое, которого «нет».
Имя в пакете есть. А ни фронтенд, ни Suricata, ни tshark его не видят как SNI. Как так?
Разгадка держится на одном числе
Ну ладно, раз умный парсер спасовал — считаем байты руками. Вот начало полезной нагрузки того самого пакета с данными, живьём из tcpdump -X:
Смещение |
Байты |
Что это означает |
|---|---|---|
|
|
|
|
|
Длина TLS Record = |
|
|
|
|
|
Legacy Version |
|
|
|
|
|
Cipher Suites и другие поля ClientHello |
|
|
Extension |
|
|
Длина |
|
|
GREASE / служебные данные key share |
|
|
NamedGroup |
|
|
Длина ключа = |
|
|
Ещё данные ClientHello |
|
|
Extension |
|
|
|
— |
— |
Эти байты находятся после фактически переданных 1348 байт |
Смотрите, что тут сошлось. Запись в самом начале честно заявляет: 01 00 05 e8 — ClientHello на 1512 байт. Плюс пятибайтный заголовок записи — весь TLS-record обещает ~1517 байт. А в пакете реально лежит tcp.len = 1348. Заявлено больше, чем прислано. И следующего сегмента с хвостом нет — сразу за этим в дампе идёт новый SYN на новый порт. Хендшейк оборван на полуслове.
Вот почему tshark не признал это за TLS. Он честный: видит запись, которая обещает 1517 байт, получает 1348, ждёт хвоста в следующем сегменте — а хвоста нет. Неполная запись — это не TLS, это оборванный поток. Отсюда пустая колонка handshake.type.
Вот почему HAProxy писал client_hello:no. Фетч req.ssl_hello_type тоже не смог подтвердить тип: чтобы сказать «да, это ClientHello», парсеру нужно достаточно данных, а их обрезали. Неполный хендшейк — «не ClientHello» с точки зрения фронтенда, хотя байт 0x01 в нём буквально есть.
И вот почему у каждого соединения пустой SNI. В этом конкретном ClientHello расширение server_name находится после большого key_share, то есть за пределом реально переданного фрагмента, на схеме выше видно, оно за обрывом пакета. Присланные 1348 байт до него не дотягивают. Имя физически в пакете... точнее, оно физически в том пакете, который инструмент так и не отправил. Парсер доходит до места, где ждёт SNI, упирается в конец недосланного хендшейка — и ставит прочерк. Всегда в одном и том же месте, поэтому у всех и стопроцентно.
Три улики — пустой handshake.type у tshark, client_hello:no у HAProxy, sni:- в логах — оказались одним и тем же фактом, увиденным с трёх сторон: ClientHello обрублен, и всё, что за обрывом, не существует ни для кого. Загадка «имя есть, а SNI нет» решена: имя в хвосте, а хвоста нет. Осталось понять, почему.
Три пакета и обрыв
Разбираю поток по одному соединению:
tshark -r ch.pcap -Y "tcp.port == 54100" -T fields \ -e tcp.seq -e tcp.ack -e tcp.len -e tcp.flags.str seq ack len flags 0 0 0 ··········S· SYN 1 1 0 ·······A···· ACK (хендшейк TCP завершён) 1 1 1348 ·······A···· ACK + 1348 байт данных → тишина
И так на всех проверенных портах — три пакета, и обрыв. Нет четвёртого пакета с хвостом ClientHello. Нет FIN, нет RST — соединение просто бросается открытым. Между SYN и данными проходит ~80–100 мс: инструмент дожидается SYN-ACK, отвечает ACK, доводит соединение до established и только потом шлёт данные. То есть TCP-хендшейк он соблюдает. А TLS-хендшейк — нет: выкладывает первый сегмент и уходит.
Теперь смотрю на IP-заголовок этого сегмента и на MSS из SYN:
tshark -r ch.pcap -Y "tcp.srcport == 54100 && tcp.len > 0" \ -T fields -e ip.len -e tcp.len ip.len tcp.len 1400 1348
MSS в SYN-опциях — 1400. Размер IP-пакета — тоже ровно 1400, впритык. Считаем: 1400 − 20 (IP) − 32 (TCP с опциями) = 1348 байт данных. Ровно столько, сколько инструмент и шлёт.
1348 — это не выбранный размер обрезка. Это физический потолок одного TCP-сегмента при данном MSS. Больше в один пакет не влезает. А ClientHello — 1512 байт, он в один сегмент не помещается. Ему нужен второй пакет с хвостом. Второго пакета нет.
Почему ClientHello раздулся до 1.5 КБ
Обычный ClientHello — две-три сотни байт. Полтора килобайта — очень много. Что там столько занимает? Разбираю расширения.
Одно расширение key_share — 1263 байта, и внутри ключ на 1216 байт. Named group 0x11EC — это X25519MLKEM768, гибридная постквантовая схема: классический X25519 плюс ML-KEM (бывший Kyber). У постквантовых механизмов ключи и капсулы кратно больше классических — это плата за стойкость к квантовому компьютеру. Отсюда и раздутый до полутора килобайт hello.
И вот ключевой момент. Это не самоделка и не экзотика. Ровно такой ClientHello сегодня шлют свежие браузеры. Индустрия массово катит постквантовый обмен ключами в TLS, и побочный эффект — ClientHello, который десятилетиями комфортно влезал в один сетевой пакет, теперь регулярно за него вылезает.
Это подтверждается независимо. Летом 2026-го в трекере Go завели issue ровно об этом: ClientHello с постквантовым обменом (и тем более вместе с постквантовыми подписями) разрастается до ~1.5 КБ и упирается в типичный MTU. Существуют открытые реализации X25519MLKEM768, где тот самый код 0x11EC совпадает с тем, что я выковырял из дампа. Форма пакета — абсолютно легитимная и современная. Странным было не то, что hello большой, а то, что он обрублен.
Диагноз
Собираем.
Инструмент атакующего конструирует полный постквантовый ClientHello на 1512 байт, запихивает в один пакет, пакет упирается в MSS, влезает только начало — и второй сегмент с хвостом не отправляется. Никогда. В его коде нет логики «данные не поместились в один сегмент, дошли остаток вторым пакетом».
Когда вы шлёте данные через обычный сокет ОС, ядро само нарежет буфер на сегменты по MSS — думать об этом не нужно, это работа стека. Но этот инструмент лепит пакеты сам, в обход ядра, вручную собирая IP- и TCP-заголовки. И сегментацию, которую ядро делало бы автоматически, авторская реализация не делает. Есть sendPacket(). Нет обработки «а если данных на два пакета».
Замысел, к слову, был неглупый. Раздутый ClientHello — способ нагрузить сервер (заставить принять и буферизовать большое сообщение, подержать под него состояние), а постквантовый key_share — способ раздуть hello правдоподобно, замаскировавшись под настоящий современный браузер вместо очевидного мусора. Но ровно та фича, ради которой всё затевалось — большой PQC-ClientHello — и убила инструмент: раздув hello за MSS, автор напоролся на то, что его самодельный стек не умеет во второй сегмент. Хендшейк не завершается ни разу, до дорогой криптографии сервер не доходит, соединение висит до таймаута с нулевым ответом. Вся маскировка под браузер бессмысленна, потому что предъявить валидный SNI, не досказав ClientHello, невозможно — а он его не досказывает.
Это не про один кривой флудер, а про сломанное допущение
Здесь легко остановиться на «попался косой инструмент, посмеялись, разошлись». Но интереснее другое.
Десятилетиями практически весь сетевой софт, который разбирает TLS руками, молча закладывался на одно допущение: ClientHello помещается в один пакет. Это было почти всегда так, и на этом строились короткие пути в куче мест:
Спуфер-инструменты и raw-packet-генераторы (обход DPI, фейковый SNI, флудеры) — читают/пишут ClientHello в пределах одного сегмента.
Middlebox'ы, firewall'ы и DPI, которые фильтруют по SNI из первого пакета, не собирая поток.
SNI-based роутеры и балансировщики, извлекающие имя «на лету».
Инструменты цензуры и антицензуры, завязанные на то, что SNI виден в первом сегменте.
Анализаторы трафика и IDS, которым нужно уметь собирать хендшейк из нескольких сегментов, чтобы вообще увидеть его целиком.
Постквантовая криптография это допущение тихо отменяет. ClientHello современного браузера теперь регулярно занимает два сегмента, и весь код, который не реализует сборку хендшейка из нескольких пакетов, начинает ломаться — причём не с ошибкой, а молча, продолжая при этом «работать». Флудер из этой статьи — просто самый заметный и самый безобидный симптом: он ломается так, что перестаёт быть угрозой. Но ровно тот же баг в middlebox'е означает, что легитимные браузеры с постквантовым TLS начнут необъяснимо отваливаться. В DPI — что фильтрация по SNI начнёт промахиваться. В анализаторе — что часть TLS-трафика перестанет распознаваться (как перестал распознаваться у tshark, пока хендшейк был неполон). И это уже всплывает: в открытых проектах разбора трафика сборка ClientHello из нескольких сегментов прямо сейчас появляется как отдельная задача, которой раньше просто не было нужды.
То есть история не про то, что кто-то написал плохой флудер. Она про то, что переход на постквантовый TLS — это не «просто поменяли алгоритм». Он сдвигает размеры на проводе и тем самым выносит на свет всё, что молча полагалось на старые габариты пакетов. Флудер попался первым, потому что он и был написан небрежнее всех. Следом пойдут вещи посерьёзнее.
Практический вывод
Один вывод — узкий, для обороны. Лучший критерий фильтрации бьёт по фундаментальному свойству, а не по поверхностному признаку. Инструмент подделал всё, что можно подделать в отдельном пакете: fingerprint браузера, постквантовый ключ, GREASE, имя домена в теле. Не смог подделать одного — завершенного хендшейка, потому что для этого нужно корректно работать с сетью, а он не умел. Фильтр, который смотрит «есть ли валидный разобранный SNI» (что требует завершённого хендшейка), ловит это насквозь, как ни раскрашивай отдельный пакет. Отсекайте по тому, что атакующему структурно дорого или невозможно подделать, а не по сигнатурам конкретных тулз, которые он поменяет за минуту.
Второй вывод — широкий, для всех, кто трогает TLS на проводе. Проверьте своё допущение про один пакет. Если ваш код (фильтр, роутер, анализатор, middlebox) извлекает что-либо из ClientHello, не собирая TCP-поток, — он уже на пути к тому, чтобы молча ломаться на постквантовом трафике. Не когда-нибудь. Он уже в дикой природе, полтора килобайта, два сегмента.
А тот, кто запускал флудер, скорее всего до сих пор уверен, что атакует: пакеты уходят, счётчик крутится. Что каждое соединение мертворожденное на транспорте, что ни один хендшейк не завершился, что инструмент разбивается о собственный постквантовый ключ раньше, чем долетает до цели, этого с его стороны не видно. Но за этим частным курьёзом стоит общий: индустрия перешла на криптографию, которая больше не влезает в старые габариты, и весь софт, тихо на эти габариты рассчитывавший, теперь предстоит переписывать. Некоторым — раньше, чем они заметят, что уже сломались.