Как Исправить Стек Tcp K̶d̶e̶ Под Freebsd

Когда встает вопрос выбора между проприетарным и открытым программным обеспечением, в пользу последнего часто приводят следующий аргумент: при необходимости вы можете изменить исходный код под нужды вашего проекта или исправить ошибку прямо сейчас, вместо того, чтобы месяцами ждать ответа поставщика.

Часто это соображение оторвано от практики — гораздо проще исправить SQL-запрос, чем оптимизировать планировщик SQL или менять проблемное оборудование вместо того, чтобы искать и исправлять ошибку в драйвере.

Однако иногда именно открытость кода позволяет избежать потенциальных потерь и чрезмерного использования вычислительных ресурсов.

Я хотел бы рассказать вам об одном из таких случаев, который произошел во время моей работы в Advanced Hosting.



Всплеск трафика и первые подозрения на DDoS

Поздно вечером в субботу один из серверов заболел.

Весь кластер этого проекта состоял из 3+3 штук, и каждый тянет на себя всю нагрузку своих трёх, поэтому потеря одного сервису не грозила.

Но всё равно было крайне неприятно, что серверы, до сих пор спокойно воспринимавшие суммарный входящий трафик 10+К http-запросов в секунду и имевшие (как казалось) кратный запас производительности, вдруг оказались не такими стабильными .

Пока RAID1 перестраивался и PostgreSQL догонял репликацию, пришло время присмотреться к остальным серверам.

Стоит заранее объяснить, как работает этот кластер.

Серверы расположены в разных местах: два в Европе и четыре в США.

Они разделены на тройки и обслуживают свою группу IP (т.е.

на каждую тройку приходится один сервер в Европе и два других в США).

Трафик распределяется методом Anycast — на всех серверах тройки прописаны одни и те же IP-адреса, а с ее прямым маршрутизатором открывается BGP-сессия.

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

Смотреть было не на что.

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

Те.

пакеты были маленькие и их было много (до 200К в секунду).

На сервисах HighLoad трафик просто так не меняется, особенно в таких размерах.

Очень похоже на DDoS, не так ли? Не могу сказать, что я был очень удивлён; Я видел много разных типов DDoS, и до сих пор, если сетевое оборудование провайдеров позволяло доставлять трафик на серверы без потерь, его всегда можно было заблокировать.

Настораживало то, что всплеск трафика был только на европейских серверах: ведь если ботнет распределенный, то трафик должен распределяться по всему кластеру.



Потеря пакетов и увеличение количества активных TCP-сессий

После запуска сервера я запустил `top`, `nload` и начал следить за нагрузкой.

Вскоре трафик снова увеличился вдвое и ssh-сессия стала заметно лагать.

Есть потери пакетов, `mtr -ni 0.1 8.8.8.8` сразу подтвердил эту гипотезу, а `top -SH` указал, что проблема в ядре ОС — процессору входящих сетевых пакетов не хватает CPU. Ну, теперь понятно, почему завис сервер — потеря пакетов для него смерти подобна.

На момент написания этого поста у FreeBSD была одна очень неприятная особенность в сетевом стеке — он плохо масштабировался относительно количества TCP-сессий.

Увеличение количества TCP-сессий в несколько раз приводит к непропорционально высокому потреблению процессора.

Пока сеансов мало, проблем нет; но начиная с нескольких десятков тысяч активных TCP-сессий обработчик входящих пакетов начинает испытывать нехватку ЦП и вынужден отбрасывать пакеты.

И это приводит к цепной реакции — из-за потерь пакетов активные TCP-сессии начинают обслуживаться медленно, их количество сразу же начинает расти, а вместе с этим увеличивается нехватка ЦП и еще больше возрастает уровень потерь пакетов.

Прежде чем сервер полностью зависнет, я срочно отключаю BGP-сессию, а заодно запускаю проверку потери пакетов на сервере, получавшем европейский трафик.

У него более мощное железо - т.е.

есть вероятность, что в Штатах ничего плохого не произойдет. Нужно что-то делать с проблемным сервером, и первое, что я делаю, это отключаю HTTP keep-alive — TCP-сессии начнут заканчиваться раньше и их в целом станет меньше.

Настройка параметров сетевой карты заняла десятки минут, проверка потери пакетов каждый раз кратковременным поднятием BGP-сессии - пришлось выйти из режима опроса, но активировать Idlepoll - теперь одно ядро процессора было занято исключительно сетевой картой, но потеря пакетов остановился.

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

Но вот что было совершенно непонятно, так это то, почему эта атака вообще не была видна на американских серверах! Во время отключения европейских серверов на сервера США поступал только текущий рабочий трафик, а дополнительного трафика не было! Хотя после возвращения трафика в Европу он некоторое время оставался на рабочем уровне, а затем начался новый всплеск.

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

С этими мыслями я снова заснул, но через пару часов меня снова разбудили — на этот раз оба европейских сервера уже лежали.

Это добавляло этому происшествию еще одну странность – ведь было уже поздно, а пик движения уже давно прошел.

Хотя, что касается DDoS-атаки, то это нормально, ведь большинство специалистов спят и проводить атаку, как правило, некому.

Оба сервера вскоре были запущены, однако последующий мониторинг ситуации не дал ничего нового — атака в этот день не повторилась.



Краткосрочное решение

В воскресенье мне пришлось немного поработать.

Отдельный скрипт уже отслеживал количество TCP-сессий и временно снимал трафик (т.е.

передавал его в Штаты) в случае увеличения нагрузки, что уменьшало получаемый ущерб.

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

В логах http никаких аномалий не было, netstat и подобные утилиты тоже ничего подозрительного не показали.

Однако если мы увидим увеличение трафика на сетевой карте, мы можем изучить это с помощью tcpdump. Пролистывать тонны дампов сетевых пакетов может быть затруднительно, но в этот раз долго искать не пришлось — среди обычных HTTP/HTTPS-обменов было видно аномально большое количество пустых TCP-пакетов, т.е.

легальных пакетов с правильным IP и TCP-заголовки, но без данных.

При отключении HTTP keep-alive уже много пустых пакетов — три пустых пакета для установления соединения, затем два пакета обмена данными (запрос-ответ), а затем снова пустые пакеты закрывают соединение.

Кроме того, при использовании HTTPS у нас также есть пакеты данных для установления сеанса TLS. Выборочная проверка отдельных TCP-сессий показала, что в некоторых сессиях действительно происходил очень интенсивный обмен пустыми TCP-пакетами.

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

Я пишу второй простой скрипт, который запускает tcpdump на 30 тыс.

пакетов каждую секунду и ищет среди них сеансы, в которых количество последовательных обменов пустыми пакетами превышает указанный предел; найденные IP-адреса немедленно блокируются.

Результат не заставил себя долго ждать — при блокировке всего пяти IP трафик сразу падает вдвое.

Каждую минуту блокировалось еще один-два новых IP. Победа!

Анализ симптомов и выявление проблемы

Обсудив этот случай с коллегами из Advanced Hosting, оказалось, что все не так радужно.

Во-первых, возросла интенсивность блокировки новых IP — уже не в часы пик трафика скорость блокировки достигала нескольких десятков единиц в минуту.

Во-вторых, пострадали не только эти серверы, но и многие другие, и другие клиенты.

Обычно всё в Европе и всё на FreeBSD. Стало понятно, что это не DDOS-атака.

Заблокированные IP-адреса пришлось освободить, и вместо блокировки теперь удалялись сами TCP-сессии (для этого во FreeBSD есть утилита tcpdrop).

Это также эффективно держало нагрузку под контролем и даже позволяло отключить поддержку активности HTTP. Нам нужно снова поднять tcpdump и посмотреть трафик дальше.

Я не буду подробно описывать часы, которые были потрачены на поиск аномалий и закономерностей в данных.

TCP-сеансы были разными.

Были какие-то совсем пустые, а какие-то обменивались данными, которые потом перешли в цикл обмена пустыми пакетами.

Но подсказка все же была.

Перед входом в цикл обмена пустыми пакетами с удаленной стороны пришёл FIN-пакет (пакет с флагом FIN сигнализирует о том, что данных больше не будет и сессию необходимо закрыть), иногда не один, а иногда и RST-пакет (пакет с флагом RST указывает на то, что сессия уже закрыта и больше не действительна).

Что интересно, несмотря на наличие пакетов FIN и RST, позже случилось так, что на сервер приходили и пакеты с данными.

Либо где-то TCP-стек реализован настолько криво, что это маловероятно, либо где-то есть грубые помехи в TCP-сессии, но это вполне вероятно (особенно любят этим баловаться мобильные операторы, не буду показывать пальцем).

Вторую версию подтвердило и то, что проверка http-журнала обнаруженных вредоносных TCP-сессий показала, что почти во всех из них задействован мобильный браузер, как Android, так и iOS. Логично было предположить, что пакет FIN или RST переводил TCP-сессию в закрытое состояние, в котором TCP-стек просто подтверждал получение пакетов.

Мне было интересно, какое конкретно состояние TCP tcp_fsm.h

  
  
   

#define TCP_NSTATES 11 #define TCPS_CLOSED 0 /* closed */ #define TCPS_LISTEN 1 /* listening for connection */ #define TCPS_SYN_SENT 2 /* active, have sent syn */ #define TCPS_SYN_RECEIVED 3 /* have sent and received syn */ /* states < TCPS_ESTABLISHED are those where connections not established */ #define TCPS_ESTABLISHED 4 /* established */ #define TCPS_CLOSE_WAIT 5 /* rcvd fin, waiting for close */ /* states > TCPS_CLOSE_WAIT are those where user has closed */ #define TCPS_FIN_WAIT_1 6 /* have closed, sent fin */ #define TCPS_CLOSING 7 /* closed xchd FIN; await FIN ACK */ #define TCPS_LAST_ACK 8 /* had fin and close; await FIN ACK */ /* states > TCPS_CLOSE_WAIT && < TCPS_FIN_WAIT_2 await ACK of FIN */ #define TCPS_FIN_WAIT_2 9 /* have closed, fin is acked */ #define TCPS_TIME_WAIT 10 /* in 2*msl quiet wait after close */

ведет себя таким образом, и перед вызовом tcpdrop я добавил поиск удаляемого сеанса TCP в выводе netstat -an. Результат немного обескуражил - все они были в состоянии УСТАНОВЛЕНО! Это уже было очень похоже на баг — закрытая TCP-сессия не может вернуться в состояние ESTABLISHED, такой возможности не предусмотрено.

Я сразу начал проверять исходники и ядра и второй раз разочаровался:

tp->t_state = TCPS_ESTABLISHED

в коде вызывается ровно два раза, и оба раза непосредственно перед этим проверяется текущее значение t_state — в одном случае оно равно TCPS_SYN_SENT (сервер отправил SYN-пакет и получил подтверждение), а во втором — TCPS_SYN_RECEIVED (сервер получил SYN, отправил SYN/ACK и получил подтверждающее ACK).

Вывод из этого вполне конкретный — пакеты FIN и RST были проигнорированы сервером, а в TCP-стеке нет никакой ошибки (по крайней мере, ошибки с некорректным переходом из одного состояния в другое).

Тем не менее, было неясно, почему сервер должен реагировать на каждый полученный TCP-пакет. Обычно в этом нет необходимости, и TCP-стек работает по-другому — он принимает несколько пакетов, а затем отправляет подтверждение для них всех одним пакетом — это более экономично.

Тщательное изучение содержимого пакетов, в частности 32-битных счетчиков TCP — Sequence (SEQ) и Acknowledgment (ACK), помогло пролить свет на ситуацию.

Поведение tcpdump по умолчанию — показывающее разницу seq/ack между пакетами вместо абсолютных значений — сыграло в данном случае плохую услугу.



16:03:21.931367 IP (tos 0x28, ttl 47, id 44771, offset 0, flags [DF], proto TCP (6), length 60) 46.153.19.182.54645 > 88.208.9.111.80: Flags [S], cksum 0x181c (correct), seq 3834615051, win 65535, options [mss 1460,sackOK,TS val 932840 ecr 0,nop,wscale 6], length 0 16:03:21.931387 IP (tos 0x0, ttl 64, id 1432, offset 0, flags [DF], proto TCP (6), length 60) 88.208.9.111.80 > 46.153.19.182.54645: Flags [S.], cksum 0xa4bc (incorrect -> 0xf9a4), seq 1594895211, ack 3834615052, win 8192, options [mss 1460,nop,wscale 6,sackOK,TS val 2509954639 ecr 932840], length 0 16:03:22.049434 IP (tos 0x28, ttl 47, id 44772, offset 0, flags [DF], proto TCP (6), length 52) 46.153.19.182.54645 > 88.208.9.111.80: Flags [.

], cksum 0x430b (correct), seq 3834615052, ack 1594895212, win 1369, options [nop,nop,TS val 932852 ecr 2509954639], length 0 16:03:22.053697 IP (tos 0x28, ttl 47, id 44773, offset 0, flags [DF], proto TCP (6), length 40) 46.153.19.182.54645 > 88.208.9.111.80: Flags [R], cksum 0x93ba (correct), seq 211128292, win 1369, length 0 16:03:22.059913 IP (tos 0x28, ttl 48, id 0, offset 0, flags [DF], proto TCP (6), length 40) 46.153.19.182.54645 > 88.208.9.111.80: Flags [R.], cksum 0xa03f (correct), seq 0, ack 1594897965, win 0, length 0 16:03:22.060700 IP (tos 0x28, ttl 47, id 44774, offset 0, flags [DF], proto TCP (6), length 52) 46.153.19.182.54645 > 88.208.9.111.80: Flags [.

], cksum 0x3a48 (correct), seq 3834615953, ack 1594896512, win 1410, options [nop,nop,TS val 932853 ecr 2509954639], length 0 16:03:22.060706 IP (tos 0x0, ttl 64, id 3974, offset 0, flags [DF], proto TCP (6), length 52) 88.208.9.111.80 > 46.153.19.182.54645: Flags [.

], cksum 0xa4b4 (incorrect -> 0x475c), seq 1594895212, ack 3834615052, win 135, options [nop,nop,TS val 2509954768 ecr 932852], length 0

Давайте внимательно посмотрим на абсолютные значения.

Первый пакет содержит seq 3834615051, в ответ сервер отправил пакет seq 1594895211, ack 3834615052 (на out-ack было отправлено число in-seq+1).

Потом пришло пару RST пакетов, они нам не интересны.

Но нам интересен следующий пакет - он содержит seq номера 3834615953, ack 1594896512. Оба эти числа значительно больше начального seq/ack, а это значит, что удаленная сторона уже отправила 3834615953-3834615052=901 байт и даже успела чтобы получить 1594896512-1594895212 =1300 байт. Этих пакетов данных мы, конечно, не видим и не увидим — этот обмен был с системой MiTM. Но сервер этого не знает. Он видит пакет с seq 3834615953, и поэтому делает вывод, что не получил 901 байт данных, и поэтому отправляет обратно пакет с последними известными ему действительными номерами seq/ack — seq 1594895212, ack 3834615052. Удаленная сторона получает это пакет, а она в свою очередь сообщает, что с ней все в порядке, 1300 байт данных получены успешно.

Здесь у нас есть цикл.

Также становится понятно, почему серверы США не видели этот трафик — он действительно был, но в разы меньше — ведь пинг из Индии в Америку больше, чем пинг из Индии в Европу.



Финальный патч

Осталось только найти, как исправить эту ошибку.

Опять берем исходники, интересующий нас код находится в файле tcp_input.c. Это было несложно, поскольку первичную обработку TCP-пакета осуществляет функция tcp_input().

Алгоритм функции устроен таким образом, что пакет отправляется на обработку в функцию tcp_do_segment() в самом конце, когда он прошел все проверки и TCP-соединение находится в состоянии ESTABLISHED. Необходимо добавить еще одну проверку — если счетчик подтверждений с удаленной стороны показывает, что она получила данные, которые сервер не отправлял, пакет нужно игнорировать.

Мы не можем сразу разорвать соединение — иначе мы откроем злоумышленникам простой способ разорвать TCP-соединения других людей.

Тестирование патча показало, что TCP-трафик также содержит пакеты с нулевым значением ack — их больше не нужно игнорировать.

Финальный патч занял три строчки (без учета комментариев):

+

Теги: #*nix #Сетевые технологии #ddos #Оптимизация сервера #Системное администрирование #Администрирование сервера #highload #tcp #FreeBSD #dpi

Вместе с данным постом часто просматривают: