Виртуальная машина eBPF, работающая в ядре Linux, приближается к десятилетнему юбилею своего включения в состав Linux; за это время она успела превратиться в инструмент, имеющий целое множество применений в этой экосистеме. Алексей Старовойтов (Alexei Starovoitov), который является создателем eBPF и занимался ее развитием, особенно на начальном этапе, выступил на открытии Linux Security Summit Europe 2023 с докладом о взаимосвязи BPF и безопасности. В нем он поделился с нами интересными историческими моментами в несколько иной перспективе, нежели они обычно раскрываются. Среди прочего, он рассказал, как BPF на протяжении всего своего пути была одновременно и проблемой безопасности, и ее решением.
BPF and Security, Friends and Foes
Универсальная сборка
BPF — это что-то вроде редактора vi, — начал Алексей, — люди либо любят его, либо ненавидят, но и то, и другое — просто последовательности команд. Существует множество определений BPF, но в рамках этого доклада он хотел использовать то, которое гласит, что это "универсальный язык ассемблера". Это первый строго типизированный язык ассемблера; в BPF нет "указателей на память", все указатели относятся к определенным типам.
Поскольку она универсальна, она выходит далеко за рамки юзкейсов, когда пространство пользователя говорит ядру, что делать; в настоящее время существуют аппаратные устройства, которые посылают BPF ядру, чтобы описать, как их использовать. Существуют также приложения типа user-space-to-user-space, в которых ядро вообще не участвует; одно приложение сообщает другому, возможно, находящемуся на другом конце света, что нужно делать.
Алексея часто спрашивают о разнице между BPF и WebAssembly. BPF не является изолированной средой (песочницей), как WebAssembly или JavaScript в браузере; песочницы не знают, какой код они собираются запускать, поэтому им приходится ограничивать среду выполнения. Они создают определенные границы, и это замедляет производительность из-за всех необходимых проверок во время выполнения.
BPF, напротив, проверяется статически, поэтому в ней "практически нет проверок во время выполнения"; проверяются только те вещи, которые верификатор не может определить статически. Основное отличие заключается в том, что намерения BPF-программы известны до ее выполнения, что совершенно не характерно для песочниц, в которых приходится выполнять произвольный код. Алексей также упомянул, что он часто встречает комментарии (в частности, на LWN) касательно добавления в ядро WebAssembly; и его ответ заключается в том, что этим разработчикам следует попробовать внедрить его ("bring it in"). Он считает, что в ядре достаточно места для WebAssembly или какой-либо другой песочницы в дополнение к BPF.
Когда eBPF был впервые предложен в 2013 г., он назывался "internal BPF" (iBPF); со временем он перерос в "extended BPF", таким образом, получив название eBPF. Помимо этого, и сам eBPF неоднократно расширялся; в LLVM эти новые наборы инструкций представляют собой различные модели процессоров, которые выбираются с помощью опции -mcpu со значениями от v1 до v4. Поддержка v4 была добавлена в июле 2023 года; именно этот набор инструкций теперь поддерживает и GCC.
Следующий слайд (номер 9 в слайдах) Алексей назвал самым важным в своей презентации. По его словам, у каждого проекта должна быть своя миссия. У BPF миссия состоит из двух частей: во-первых, "внедрять инновации" и, во-вторых, "давать возможность другим внедрять инновации". Этот проект не только удовлетворяет его "жажду инноваций", но и позволяет другим делать новые вещи. Одна из его самых любимых частей в работе над BPF — это помощь людям, которые пишут в список рассылки о чем-то новом, что они пытаются сделать; это "лучший аспект работы в качестве мейнтейнера ядра".
По его мнению, этот дух проекта проявляется в росте сообщества разработчиков BPF. Он показал график уникальных разработчиков по месяцам с начала 2019 года, который демонстрирует стабильный рост с примерно 50 до более чем 100 человек за этот период, в то время как команда BPF в Meta, в которую он входит, оставалась примерно той же — около десяти или пятнадцати разработчиков за тот же период.
Трассировка и сетевое взаимодействие
"BPF уходит корнями в трассировку", — говорит Алексей. Первым хуком, к которому можно было подцепить программу eBPF, был хук для kprobes и uprobes; затем появились точки трассировки, затем вход в функцию (fentry) и выход из нее (fexit). Люди часто думают, что BPF может делать в ядре все, что угодно, но на самом деле она довольно ограничена. Касательно точек трассировки, BPF-программа может читать любые данные ядра, но не может их изменять. Сетевые BPF-программы могут читать и модифицировать данные пакетов и отбрасывать пакеты, но они не могут модифицировать состояние ядра. Ограничения, накладываемые на различные типы BPF-программ, прежде всего основаны юзкейсах, для работы с которыми они предназначены.
Алексей привел несколько примеров трассировки BPF. По его словам, Android использует BPF-программы для отслеживания использования сети в зависимости от хоста, с которым осуществляется связь. Так, если пользователь хочет посмотреть статистику использования Facebook на своем телефоне в сравнении, например, с использованием YouTube, он получит эту информацию с помощью BPF-программы. Программа PyPerf использует BPF для профилирования Python-программ. Кроме того, BPF-программы могут быть присоединены к user-space-программам с помощью uprobes, так что при любом вызове программы к ней будет присоединена BPF-программа. Это можно использовать для того, чтобы узнать, сколько времени GCC тратит на обработку include-файлов по сравнению с компиляцией кода; каждый вызов GCC в параллельном make будет корректно инструментироваться для сбора этих данных.
Кроме того, BPF имеет множество сетевых юзкейсов. Алексей отметил, что функция express data path (XDP) в ядре появилась как способ защиты от DDoS-атак. В свое время компания Facebook подверглась DDoS-атаке мощностью 500 Гбит/с, которая была отражена путем установки BPF-программы на уровне сетевого драйвера с использованием XDP. По его словам, такой способ отражения атаки позволил улучшить результативность в 10 раз по сравнению с более ранними методами борьбы с DDoS.
Безопасность
Возможность присоединения BPF-программ к хукам модуля безопасности Linux (LSM) (также известная как BPF-LSM) — это недавнее дополнение к ядру, которое используется для "предотвращения любопытных атак". Как и другие типы BPF-программ, BPF-программы, которые могут быть присоединены к LSM хукам (или к системным вызовам), обладают специфическими возможностями, отличными от возможностей программ для трассировки, сетевого взаимодействия или других типов программ. Эти программы могут читать произвольные данные ядра и запрещать операции, но они также могут засыпать, что является чем-то новым для BPF-программ. Речь идет о том, что программа может вызвать незначительный сбой, если user-space-адрес, к которому она обращается, был выгружен, в результате чего обойти эти хуки, обратившись к выгруженной памяти, невозможно.
К сожалению, BPF-LSM-программы, как правило, не являются общедоступными, в отличие, скажем, от программ для трассировки и работы с сетями. По крайней мере 90% известных Алексею программ в этих областях находятся в свободном доступе; в частности, многие крупные интернет-компании совместно работают над такими вещами, как защита от DDoS, делятся своим кодом и учатся друг у друга. То же самое можно сказать и о трассировке, но с кодом BPF-LSM "все не так хорошо".
По его словам, набор функций BPF для работы с сетями, трассировки и даже безопасности уже практически готов: они реализованы на 95%, хотя, конечно, последние 5% требуют гораздо больше времени. Но в BPF все еще добавляются новые фичи, в том числе недавно появившаяся функция BPF for human interface device (HID). Это позволяет BPF-программам изменять то, как HID-устройства, такие как клавиатуры и мыши, воспринимаются ядром, исправлять проблемы (особенности) или каким-либо образом изменять их поведение.
По словам Старовойтова, он очень рад появлению расширяемого класса планировщика, который позволит BPF-программам выполнять функции планирования для тестирования новых алгоритмов планирования. Всегда есть нишевые случаи использования, когда требуется более специализированный планировщик, особенно в облачных рабочих нагрузках, где планировщики в виртуальных машинах вынуждены бороться с планировщиком гипервизора. Пока, по крайней мере, от подключаемых планировщиков с использованием BPF отказались, хотя об этом в докладе не говорилось.
Непривилегированный BPF
Оригинальный набор инструкций Berkeley Packet Filter (BPF) был создан 30 лет назад; он продолжает существовать в Linux в виде "classic BPF" (cBPF) и используется в программах tcpdump и seccomp(). Использование cBPF является непривилегированным, поэтому eBPF последовал его примеру: два из 32 типов BPF-программ могут использоваться без привилегий, и оба позволяют только читать пакетные данные и отбрасывать пакеты. Один из них, BPF_PROG_TYPE_SOCKET_FILTER, по словам Алексея, практически не используется; все остальные типы программ всегда требовали привилегий root.
По его словам, первые несколько лет существования eBPF в ядре все было хорошо — до 2017 года. Именно тогда Янн Хорн (Jann Horn) из Project Zero написал код BPF, демонстрирующий проблемы спекулятивного выполнения, которые в итоге стали известны как Spectre v1. Все современные процессоры реализуют спекулятивное выполнение, но побочные эффекты их неверных предсказаний все равно остаются в кэшах, поэтому их невозможно скрыть. Как было показано в конце 2017 года (и в последующие годы), эти побочные эффекты могут быть превращены в уязвимости безопасности.
В ответ на эти проблемы производители аппаратного обеспечения рекомендовали остановить возможные ошибки предсказания ветвлений путем добавления в код инструкций "заграждения загрузки" (lfence). Microsoft последовала этому совету и изменила свои компиляторы таким образом, чтобы они повсеместно выдавали эти инструкции, после чего были переделаны и Windows с рядом других инструментов.
По словам Алексея, производители аппаратного обеспечения просили разработчиков ядра Linux сделать то же самое, но у них были другие идеи. Инструкция lfence — это большой молоток, имеющий серьезные последствия для производительности, поэтому было решено, что ядро будет управлять спекуляциями, направляя их в безопасное русло, а не отключать их с помощью lfence. Потребовалось немало усилий, чтобы убедить Intel и Arm в целесообразности такой методики, но в результате удалось добиться лучшего решения проблемы. Макрос array_index_nospec() из этого патча сегодня используется в ядре 240 раз, причем некоторые из этих использований — в критческих путях, например, при поиске индексов в таблице файловых дескрипторов. Влияние использования вместо него инструкции lfence было бы огромным.
В эксплойте Хорна использовался BPF, поэтому изменения потребовались и в нем. BPF не может использовать макрос напрямую, но он вносит эквивалентное изменение, чтобы избежать Spectre v1. Однако всего через несколько месяцев Хорн вернулся с эксплойтом Spectre v2, который использовал интерпретатор BPF, что вызвало дополнительные опасения по поводу безопасности BPF. Эксплойт не загружал код BPF в ядро; спекулятивное выполнение использовало интерпретатор для инструкций BPF, которые находились в пространстве пользователя.
Решение заключалось в том, чтобы избежать присутствия кода интерпретатора в исполняемом файле ядра. Код BPF может быть либо интерпретирован, либо запущен с помощью just-in-time (JIT) компилятора, поэтому была добавлена опция BPF_JIT_ALWAYS_ON, чтобы всегда включать компилятор для BPF и удалять интерпретатор. Хотя BPF был изменен, чтобы избежать этой проблемы (которая, конечно же, в действительности кроется в аппаратной части процессора), он считает, что любой интерпретатор в ядре может быть использован подобным образом; существует еще как минимум три интерпретатора, поэтому ядро все еще не полностью безопасно, сказал Старовойтов.
Алексей обратил внимание насколько интересно менялось восприятие JIT-компилятора BPF с годами. В 2011 году на него была совершена атака с распылением JIT, что заставило некоторых разработчиков ядра задуматься о том, есть ли JIT-компиляции место в ядре. В то время эта проблема была решена, но теперь JIT-компилятор должен быть включен, чтобы избежать Spectre v2. Разработчики BPF также обнаружили, что JIT-компилятор восстанавливает производительность, потерянную из-за retpolines, которые являются еще одним средством защиты от Spectre v2.
В 2019 году разработчики BPF решили сделать верификатор более умным, чтобы избежать других проблем спекулятивного выполнения. Даниэль Боркманн (Daniel Borkmann) работал над изменениями в верификаторе, чтобы обнаружить и избежать этих проблем. Для этого верификатор моделирует как обычное, так и спекулятивное выполнение, что "является уникальным достижением, ни один другой инструмент статического анализа не может выполнить подобный спекулятивный анализ".
Вскоре появился Spectre v4, который был исправлен с помощью нескольких строк кода в верификаторе для санации стека. Но другие варианты Spectre продолжали появляться, поэтому в итоге было решено добавить конфигурационную опцию, позволяющую полностью отключить непривилегированный BPF. По словам Старовойтова, два типа программ, которые можно было использовать без привилегий, относились к "крайне нишевым случаям использования с числом пользователей меньше, чем количество пальцев на руке"; продолжать поддерживать эту возможность просто не стоило, чтобы не доставлять неудобства BPF-сообществу. Опция BPF_UNPRIV_DEFAULT_OFF по умолчанию имеет значение "on", чтобы дистрибутивы не разрешали работу непривилегированных BPF-программ, однако администраторы могут отменить этот выбор.
CAP_BPF
На протяжении многих лет постоянно возникали просьбы отделить некоторые права BPF от привилегий root (фактически CAP_SYS_ADMIN), необходимых для выполнения практически всех действий BPF. Возможность CAP_PERFMON изначально была добавлена подсистемой perf, но она была принята и в BPF; она позволяет читать память ядра. Возможность CAP_BPF была добавлена для управления различными типами BPF-операций; она может быть объединена с CAP_PERFMON для загрузки полезных программ трассировки или с CAP_NET_ADMIN для загрузки полезных сетевых программ — и то, и другое не требует использования CAP_SYS_ADMIN.
Однако, по его словам, существует проблема восприятия CAP_BPF: неясно, что именно она должна регулировать. Частично проблема заключается в том, что BPF не ограничена пространствами имен; если вы можете взглянуть на память ядра, то вы можете просмотреть всю память ядра, а не только ту, которая находится в одном контейнере. CAP_BPF должна работать подобно CAP_SYS_MODULE, которая является возможностью, необходимой для загрузки модуля ядра; эта возможность фактически дает разрешение на разрушение ядра, поскольку вредоносные (или ошибочные) модули именно этим и будут заниматься.
Однако ошибки в верификаторе могут привести к аварийному завершению работы BPF-программ в ядре, что и следовало бы ожидать, но вместо этого рассматривается как дыра в безопасности, говорит Старовойтов. Таким образом, каждая ошибка верификатора получает CVE, что является реальной проблемой. Он отметил, что в середине сентября в LWN появилась статья на тему "ложных CVE", которая является проблемой и для проекта BPF. Ошибки исправляются, но на них подаются CVE для более ранних версий ядра, для которых не были сделаны бэкпорты; иногда эти CVE даже ссылаются на код самотестирования, который BPF запускает, чтобы убедиться, что ошибка остается исправленной. Наличие CVE выливается в панику, связанную с исправлением старых версий ядра.
Некоторые стартапы в области безопасности используют BPF очень странным образом. Алексей отметил один неназванный стартап, который жаловался на то, что может сделать BPF, и на опасности для систем, присущие наличию BPF; все это, конечно, делалось для того, чтобы продать продукт стартапа. Странно то, что продукт использовал BPF для защиты от всех проблем, связанных с BPF, на которые он жаловался. По сути они говорили: "BPF — это плохо, используйте BPF для защиты от BPF".
Время, отведенное на доклад, стало подходить к концу, поэтому он начал быстро переходить к остальной части своего выступления. Он отметил, что одной из немногих BPF-LSM программ с открытым исходным кодом является программа, используемая systemd для предотвращения монтирования типов файловых систем на основе разрешающих и запрещающих списков. Он показал еще несколько примеров использования BPF в системе systemd для реализации различных политик безопасности. В некоторых случаях для этого используются BPF-LSM хуки, а в других — другие типы BPF-программ (например, сетевые и трассировочные).
Алексей заявил, что, по его мнению, все модули ядра действительно должны быть написаны как BPF-программы. Преимущества модулей ядра в том, что они могут быть написаны как произвольный код на языке C, с полным доступом к символам ядра, но это означает, что они также могут привести к аварийному завершению работы ядра из-за ошибки. Для BPF-программ безопасность заложена в них через верификатор. Кроме того, BPF-программы более переносимы, чем модули ядра. Эта переносимость — недооцененное преимущество, особенно для компаний с большим парком систем, в длинном хвосте которых будет множество различных версий ядра.
В заключение он отметил, что, по его мнению, версия языка C, используемая в BPF, является лучшей версией C для программирования ядра. Безопасность, встроенная в верифицируемую версию C, является лучшим выбором для программирования ядре в целом. На его слайдах было показано несколько багов, которых можно было бы избежать, но он не стал вдаваться в подробности, хотя некоторые из них упоминались в докладе годичной давности. Можно предположить, что это мнение не является широко распространенным за пределами BPF-сообщества — по крайней мере, пока.
[Я хотел бы поблагодарить спонсора поездки LWN, Linux Foundation, за помощь в организации поездки в Бильбао на LSSEU].
Изучить или повторить работу с Linux с нуля можно на подготовительном курсе в OTUS. Курс доступен в записи, поэтому учиться можно в удобное время. Сейчас этот мини-курс можно приобрести за 10 рублей. Подробнее