Автор Марек Майковски

Друг дал мне интересную задачу: извлечь значения IP TTL из TCP-соединений, установленных программой пользовательского пространства. Эта, казалось бы, простая задача быстро превратилась в грандиозный взлом системного программирования Linux. Код результата сильно перестроен, но, черт возьми, мы многому научились в процессе!

Контекст

Вы можете задаться вопросом, почему она хотела проверить поле пакета TTL (формально известное как «IP Time To Live (TTL)» в IPv4 или «Hop Count» в IPv6)? Причина проста - она ​​хотела убедиться, что соединения маршрутизируются за пределы нашего центра обработки данных. «Расстояние перехода» - разница между значением TTL, установленным исходной машиной, и значением TTL в пакете, полученном в пункте назначения, - показывает, сколько маршрутизаторов пересек пакет. Если пакет прошел через два или более маршрутизатора, мы знаем, что он действительно пришел извне нашего центра обработки данных.

Нечасто смотреть на значения TTL (за исключением того, что они предназначены для смягчения петель маршрутизации, проверяя, когда TTL достигает нуля). Обычный способ справиться с возникшей проблемой - занести в черный список диапазоны IP-адресов наших серверов. Но в нашей настройке все не так просто. Наша конфигурация IP-нумерации довольно необычна, с большим количеством диапазонов Anycast, Unicast и зарезервированных IP-адресов. Некоторые принадлежат нам, некоторые - нет. Мы хотели избежать необходимости вести жестко запрограммированный черный список диапазонов IP-адресов.

Суть идеи такова: мы хотим отметить значение TTL из возвращенного пакета SYN + ACK. Имея это число, мы можем оценить Hop Distance - количество маршрутизаторов на пути. Если расстояние прыжка:

  • ноль: мы знаем, что соединение было подключено к локальному хосту или локальной сети.

  • один: соединение прошло через наш маршрутизатор и было прервано сразу после него.

  • два: соединение прошло через два маршрутизатора. Скорее всего наш роутер, да еще один рядом с ним.

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

Не так просто

Значения TTL легко считывать из пользовательского приложения, не так ли? Нет. Оказывается, это практически невозможно. Вот теоретические варианты, которые мы рассмотрели на раннем этапе:

A) Запустите сырой сокет, подобный libpcap / tcpdump, и вручную перехватите SYN + ACK. Мы быстро исключили этот дизайн - он требует повышенных привилегий. Кроме того, необработанные сокеты довольно хрупки: они могут потерять пакеты, если приложение пользовательского пространства не успевает за ними.

Б) Используйте опцию сокета IP_RECVTTL. IP_RECVTTL запрашивает данные «cmsg» для присоединения к управляющим / вспомогательным данным в recvmsg() системном вызове. Это хороший выбор для соединений UDP, но этот параметр сокета не поддерживается сокетами TCP SOCK_STREAM.

Извлечь TTL не так-то просто.

SO_ATTACH_FILTER, чтобы править миром!

Подождите, есть и третий способ!

Видите ли, в течение некоторого времени к сокету можно было подключить программу фильтрации BPF. См. socket(7)

SO_ATTACH_FILTER (since Linux 2.2), SO_ATTACH_BPF (since Linux 3.19)     
    Attach a classic BPF (SO_ATTACH_FILTER) or an extended BPF  
    (SO_ATTACH_BPF) program to the socket for use as a filter of 
    incoming packets. A packet will be dropped if the filter pro‐ 
    gram returns zero. If the filter program returns a nonzero value 
    which is less than the packet's data length, the packet will be 
    truncated to the length returned. If the value returned by the 
    filter is greater than or equal to the packet's data length, the 
    packet is allowed to proceed unmodi‐ fied.

Вы, вероятно, уже пользуетесь преимуществом SO_ATTACH_FILTER: именно так tcpdump / wirehark выполняет фильтрацию, когда вы выгружаете пакеты по сети.

Как это работает? В зависимости от результата программы BPF пакеты могут быть отфильтрованы, усечены или переданы в сокет без изменений. Обычно SO_ATTACH_FILTER используется для сокетов RAW, но, что удивительно, фильтры BPF также могут быть присоединены к обычным сокетам SOCK_STREAM и SOCK_DGRAM!

Однако мы не хотим обрезать пакеты - мы хотим извлечь TTL. К сожалению, с классическим BPF (cBPF) невозможно извлечь какие-либо данные из работающей программы фильтрации BPF.

eBPF и карты

Это изменилось с появлением современного оборудования BPF, которое включает в себя:

Байт-код eBPF можно рассматривать как расширение классического BPF, но именно дополнительные функции позволяют ему сиять.

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

Чтобы решить нашу проблему TTL, мы можем использовать программу фильтрации eBPF. Он будет смотреть на значения TTL проходящих пакетов и сохранять их на карте eBPF. Позже мы можем проверить карту eBPF и проанализировать записанные значения из пользовательского пространства.

SO_ATTACH_BPF править миром!

Чтобы использовать eBPF, нам нужно настроить несколько вещей. Во-первых, нам нужно создать карту eBPF. Существует много специализированных типов карт, но для наших целей воспользуемся хеш-типом BPF_MAP_TYPE_HASH.

Нам нужно выяснить параметры «bpf (BPF_MAP_CREATE, тип карты, размер ключа, размер значения, ограничение, флаги)». Для нашей небольшой программы TTL давайте установим размер ключа 4 байта и размер значения 8 байтов. Максимальный предел элемента установлен на 5. Это не имеет значения, мы ожидаем, что все пакеты в одном соединении в любом случае будут иметь только одно согласованное значение TTL.

Вот как это будет выглядеть в коде Голанга:

bpfMapFd, err := ebpf.NewMap(ebpf.Hash, 4, 8, 5, 0)

Здесь необходимо одно предупреждение. Карты BPF используют ресурс «заблокированной памяти». С несколькими программами и картами BPF легко исчерпать крошечный лимит по умолчанию в 64 КиБ. Попробуйте использовать ulimit -l, например:

ulimit -l 10240

Системный вызов bpf() возвращает дескриптор файла, указывающий на карту BPF ядра, которую мы только что создали. С его помощью мы можем работать на карте. Возможные операции:

  • bpf(BPF_MAP_LOOKUP_ELEM, <key>)
  • bpf(BPF_MAP_UPDATE_ELEM, <key>, <value>, <flags>)
  • bpf(BPF_MAP_DELETE_ELEM, <key>)
  • bpf(BPF_MAP_GET_NEXT_KEY, <key>)

Подробнее об этом позже.

После создания карты нам нужно создать программу BPF. В отличие от классического BPF, где байт-код был параметром для SO_ATTACH_FILTER, байт-код теперь загружается системным вызовом bpf(). А именно: bpf(BPF_PROG_LOAD).

В нашей программе Golang настройка программы eBPF выглядит так:

ebpfInss := ebpf.Instructions{ ebpf.BPFIDstOffSrc(ebpf.LdXW, ebpf.Reg0, ebpf.Reg1, 16), ebpf.BPFIDstOffImm(ebpf.JEqImm, ebpf.Reg0, 3, int32(htons(ETH_P_IPV6))), ebpf.BPFIDstSrc(ebpf.MovSrc, ebpf.Reg6, ebpf.Reg1), ebpf.BPFIImm(ebpf.LdAbsB, int32(-0x100000+8)), ... ebpf.BPFIDstImm(ebpf.MovImm, ebpf.Reg0, -1), ebpf.BPFIOp(ebpf.Exit), } bpfProgram, err := ebpf.NewProgram(ebpf.SocketFilter, &ebpfInss, "GPL", 0)

Написание eBPF вручную довольно спорно. Большинство людей используют clang (начиная с версии 3.7 и далее) для компиляции кода, написанного на диалекте C, в байт-код eBPF. Полученный байт-код сохраняется в файле ELF, который может быть загружен большинством библиотек eBPF. Этот файл ELF также включает описание карт, поэтому вам не нужно настраивать их вручную.

Лично я не вижу смысла добавлять зависимость ELF / clang для простых сниппетов SO_ATTACH_BPF. Не бойтесь сырого байт-кода!

Соглашение о вызовах BPF

Прежде чем мы двинемся дальше, мы должны выделить несколько моментов о среде eBPF. Официальная документация ядра не слишком дружелюбна:

Первое, что нужно знать, - это соглашение о вызовах:

  • R0 - возвращаемое значение из функции ядра и значение выхода для программы eBPF
  • R1-R5 - аргументы из программы eBPF в функцию ядра
  • R6-R9 - вызываемые сохраненные регистры, которые сохранит функция ядра
  • R10 - указатель кадра только для чтения для доступа к стеку

Когда BPF запущен, R1 содержит указатель на ctx. Эта структура данных определяется как struct __sk_buff. Например, чтобы получить доступ к полю protocol, вам нужно запустить:

r0 = *(u32 *)(r1 + 16)

Или другими словами:

ebpf.BPFIDstOffSrc(ebpf.LdXW, ebpf.Reg0, ebpf.Reg1, 16),

Именно это мы и делаем в первой строке нашей программы, поскольку нам нужно выбирать между ветвями кода IPv4 или IPv6.

Доступ к полезной нагрузке BPF

Далее идут специальные инструкции по загрузке полезной нагрузки пакета. Большинство программ BPF (но не все!) Выполняются в контексте фильтрации пакетов, поэтому имеет смысл ускорить поиск данных, используя магические коды операций для доступа к пакетным данным.

Вместо разыменования контекста, например ctx->data[x] для загрузки байта, BPF поддерживает инструкцию BPF_LD, которая может сделать это за одну операцию. Однако в документации есть предостережения:

eBPF has two non-generic instructions: (BPF_ABS | <size> | BPF_LD) and (BPF_IND | <size> | BPF_LD) which are used to access packet data. They had to be carried over from classic BPF to have strong performance of socket filters running in eBPF interpreter. These instructions can only be used when interpreter context is a pointer to 'struct sk_buff' and have seven implicit operands. Register R6 is an implicit input that must contain pointer to sk_buff. Register R0 is an implicit output which contains the data fetched from the packet. Registers R1-R5 are scratch registers and must not be used to store the data across BPF_ABS | BPF_LD or BPF_IND | BPF_LD instructions.

Другими словами: перед вызовом BPF_LD мы должны переместить ctx на R6, например:

ebpf.BPFIDstSrc(ebpf.MovSrc, ebpf.Reg6, ebpf.Reg1),

Затем мы можем вызвать нагрузку:

ebpf.BPFIImm(ebpf.LdAbsB, int32(-0x100000+7)),

На этом этапе результат находится в r0, но мы должны помнить, что r1-r5 следует считать грязными. Для инструкции BPF_LD очень похож на вызов функции.

Смещение Magical Layer 3

Затем обратите внимание на смещение загрузки - мы загрузили -0x100000+7 байт пакета. Это волшебное смещение - еще одно любопытство контекста BPF. Оказывается, сценарий BPF, загруженный под SO_ATTACH_BPF на сокете SOCK_STREAM (или SOCK_DGRAM), по умолчанию будет видеть только уровень 4 и более высокие уровни OSI. Чтобы извлечь TTL, нам нужен доступ к заголовку уровня 3 (то есть заголовку IP). Чтобы получить доступ к L3 в контексте L4, мы должны компенсировать поиск данных магическим -0x100000.

Эта магическая константа определена в ядре.

Для полноты, +7 - это, конечно, смещение поля TTL в пакете IPv4. Наша небольшая программа BPF также поддерживает IPv6, где значение TTL / Hop Count равно смещению +8.

Возвращаемое значение

Наконец, значение, возвращаемое программой BPF, имеет смысл. В контексте фильтрации пакетов это будет интерпретироваться как усеченная длина пакета.
Если бы мы вернули 0, пакет был бы отброшен и не был бы замечен приложением сокета пользовательского пространства. Довольно интересно, что мы можем обрабатывать данные на основе пакетов с помощью eBPF на потоковом сокете. В любом случае наш скрипт возвращает -1, что при приведении к беззнаковому будет интерпретироваться как очень большое число:

ebpf.BPFIDstImm(ebpf.MovImm, ebpf.Reg0, -1), 
ebpf.BPFIOp(ebpf.Exit),

Извлечение данных с карты

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

var ( 
          value MapU64 
          k1, k2 MapU32 
) 
for { 
          ok, err := bpfMap.Get(k1, &value, 8) 
          if ok { 
                  // k1 is TTL, value is counter 
                  ... 
          } 
          ok, err = bpfMap.GetNextKey(k1, &k2, 4) 
          if err != nil || ok == false { 
                  break 
          } 
          k1 = k2 
}

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

Теперь, когда все готово, мы можем сделать из нее корректно работающую программу. Здесь нет смысла обсуждать это, поэтому позвольте мне сослаться на исходный код. Детали БПФ находятся здесь:

Мы не обсуждали, как перехватить входящий SYN + ACK в программе BPF. Это вопрос настройки BPF перед вызовом connect(). К сожалению, настроить net.Dial в Golang невозможно. Вместо этого мы написали удивительно болезненную и ужасную кастомную реализацию Dial. Уродливый пользовательский код дозвона находится здесь:

Для запуска всего этого вам потребуется ядро ​​4.4+ Ядро со скомпилированным системным вызовом bpf(). Функции BPF конкретных ядер описаны на этой превосходной странице из BCC:

Запустите код, чтобы увидеть количество скачков TTL:

$ ./ttl-ebpf tcp4://google.com:80 tcp6://google.com:80 \    
             tcp4://cloudflare.com:80 tcp6://cloudflare.com:80 
[+] TTL distance to tcp4://google.com:80 172.217.4.174 is 6 
[+] TTL distance to tcp6://google.com:80 [2607:f8b0:4005:809::200e] is 4 [+] TTL distance to tcp4://cloudflare.com:80 198.41.215.162 is 3 
[+] TTL distance to tcp6://cloudflare.com:80 [2400:cb00:2048:1::c629:d6a2] is 3

Выводы

В этом сообщении блога мы погрузились в новый механизм eBPF, включая системный вызов bpf(), карты и SO_ATTACH_BPF. Эта работа позволила мне реализовать потенциал запуска SO_ATTACH_BPF на полностью установленных сокетах TCP. Несомненно, eBPF по-прежнему требует большого количества любви и документации, но кажется идеальным мостом для предоставления низкоуровневых переключателей для приложений пользовательского пространства.

Я настоятельно рекомендую, чтобы зависимости были небольшими. Для небольших программ BPF, подобных показанной, нет необходимости в сложной компиляции clang и загрузке ELF. Не бойтесь байт-кода eBPF!

Мы коснулись только SO_ATTACH_BPF, где мы проанализировали сетевые пакеты с BPF, работающим на сетевых сокетах. Это еще не все! Во-первых, вы можете прикрепить BPF к десятку вещей, наиболее очевидный пример - XDP. "Полный список". Затем можно реально повлиять на обработку пакетов ядра, вот полный список вспомогательных функций, некоторые из которых могут изменять структуры данных ядра.

В феврале LWN в шутку писали:

Developers should be careful, though; this could prove to be a slippery slope leading toward something that starts to look like a microkernel architecture.

В этом есть доля правды. Может быть, возможность запускать eBPF на различных подсистемах ощущается как кодирование с использованием микроядра, но определенно SO_ATTACH_BPF пахнет моделью программирования STREAMS 1984 года.

Спасибо Gilberto Bertin и David Wragg за помощь с байт-кодом eBPF.

Интересно, как работает eBPF? Присоединяйтесь к нашей всемирно известной команде в Лондоне, Остине, Сан-Франциско, Шампейне и нашему элитному офису в Варшаве, Польша.

Первоначально опубликовано на blog.cloudflare.com 29 марта 2018 г.