Pull to refresh

Как пропатчить K̶D̶E̶ TCP-стек под FreeBSD

Reading time 10 min
Views 14K
Когда стоит вопрос выбора между проприетарным и открытым программным обеспечением, часто в пользу последнего приводят следующий аргумент: при необходимости можно изменить исходники под нужды своего проекта, или исправить ошибку прямо сейчас, а не дожидаясь месяцами реакции вендора. Зачастую это соображение является оторванным от практики — куда проще исправить SQL запрос, чем оптимизировать SQL-планировщик, или поменять проблемное оборудование вместо того, чтобы искать и исправлять ошибку в драйвере. Тем не менее, иногда именно открытость кода позволяет избежать потенциальных убытков и перерасхода вычислительных ресурсов. Хочу рассказать об одном из таких кейсов, случившемся за время моей работы в Advanced Hosting

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


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

Стоит заранее объяснить, как устроен данный кластер. Сервера стоят в разных местах, два в Европе и четыре в USA. Они разбиты на тройки и обслуживают свою группу IP (т.е. для каждой тройки один сервер в Европе и остальные два в USA). Трафик распределяется средствами anycast — на всех серверах тройки прописаны одни и те же IP адреса, и поднята BGP-сессия с его непосредственным роутером. Если какой-либо сервер ложится, то соответствующий роутер перестает анонсировать его сеть в Internet и трафик автоматически уходит на оставшиеся сервера.

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

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

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


После ввода сервера в строй я запустил `top`, `nload` и стал мониторить загрузку. Вскоре трафик опять поднялся в два раза и ssh сессия начала ощутимо лагать. Налицо потери пакетов, `mtr -ni 0.1 8.8.8.8` данную гипотезу сразу и подтвердил, а `top -SH` указал, что дело в ядре ОС — обработчику входящих сетевых пакетов не хватает CPU. Что же, теперь понятно, почему завис сервер — потери пакетов ему смерти подобны.

У FreeBSD на момент написания поста была одна весьма неприятная особенность в сетевом стеке — он плохо масштабируется относительно количества TCP-сессий. Увеличение кол-ва TCP-сессий в несколько раз приводит к непропорционально бОльшему потреблению CPU. Пока сессий немного, проблем нет; но начиная с нескольких десятков тысяч активных TCP-сессий обработчик входящих пакетов начинает испытывать нехватку CPU и ему приходится дропать пакеты. А это приводит к цепной реакции — из-за потерь пакетов активные TCP-сессии начинают медленно обслуживаться, тут-же начинает расти их количество, а с ним растет и нехватка CPU и дальше поднимается уровень потерь пакетов.

Пока сервер окончательно не завис, срочно тушу BGP-сессию, и параллельно запускаю проверку потерь пакетов на том сервере, что принял на себя европейский трафик. Он имеет более мощное железо — т.е. есть шансы, что в Штатах ничего плохого не случится. С проблемным сервером нужно что-то делать и первым делом я выключаю HTTP keep-alive — TCP-сессии начнут завершаться раньше и в сумме их будет уже меньше. Тюнинг настроек сетевой карты занял не один десяток минут, проверяя наличие потерь пакетов каждый раз кратковременным поднятием BGP-сессии — пришлось оставить polling режим, но активировать 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 keep-alive.

Надо опять брать в руки 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`. Результат был немного обескураживающим — они все были в состоянии ESTABLISHED! Это уже было сильно похоже на баг — не может закрытая 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) и Acknowledgement (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. Оба эти номера существенно больше чем initial 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.

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

Тестирование патча показало, что в TCP-трафике также присутствуют пакеты с нулевым значением ack — их игнорировать уже не надо. Итоговый патч занял три строчки (без учета комментариев):
+	if(SEQ_GT(th->th_ack, tp->snd_max) && th->th_ack != 0) {
+		goto dropunlock;
+	}

PR (problem report) разработчикам FreeBSD отправлен в тот же день.

P.S. Как с этой проблемой обстоят дела в Linux и Windows? А там все нормально, такие пакеты игнорируются (тестировал Windows 10 и Linux 3.10).
Tags:
Hubs:
+60
Comments 35
Comments Comments 35

Articles