Материалы

Часть 2. Tetragon: лучшие практики и нюансы разработки Tracing Policy

Tetragon: лучшие практики и нюансы разработки Tracing Policy. Продолжение статьи

Блок привязки

Основной блок логики политики, который отвечает за саму привязку политики к одной из поддерживаемых Tetragon сущностей. От выбора типа привязки зависит много, так как некоторые из них нацелены на достаточно нишевые сущности, то можно разделить имеющиеся привязки на основные и дополнительные, первые могут активно использоваться для мониторинга, вторые предназначены для нишевых случаев, которые используются достаточно редко. Из основных привязок стоит выделить следующие:
  • kprobes – основной тип привязки через механизм ядра kprobes, умеет работать как с функциями ядра и LSM-хуками, так и с системными вызовами, поэтому максимально универсальный;
  • tracepoints – позволяет осуществлять мониторинг через расставленные в ядре трейспоинты, менее универсален чем kprobes, но также может использоваться, также умеет мониторить системные вызовы;
  • lsmhooks – позволяет работать с LSM-хуками, с одной стороны это существенно ограничивает число мест для перехвата, но через подсистему LSM должны проходить все вызовы, поэтому является достаточно универсальным механизмом.
И дополнительные виды привязок:
  • uprobes – позволяет мониторить процессы в пользовательском пространстве, но есть множество нюансов в использовании этого механизма;
  • usdts – аналогично uprobes позволяет мониторить процессы в пользовательском пространстве, но только при наличии специально заданных при сборке приложения точек отладки (или лучше проб?).
В одной политике может быть только один блок привязки, так как от этого зависит формат генерируемой eBPF-программы.
Kprobes
Механизм kprobes – универсальный способ перехвата практически любой функции в ядре (кроме функций реализующих сам механизм kprobes и некоторых других вроде do_page_fault и notifier_call_chain), начиная от LSM-хуков и других функций ядра и заканчивая функциями системных вызовов. Для последних нужно будет указать, что это системный вызов:
spec:
kprobes:
call: "sys_connect" syscall: true return: true
В этом примере мы устанавливаем перехват для системного вызова connect, в примере также установлена опция return для получения возвращаемого значения функции, стоит отметить, что для неё регистрируется отдельная сущность kretprobe, поэтому стоит устанавливать её по необходимости, тогда и политика будет загружаться быстрее, да и не будет лишних вмешательств в код ядра, что в случае частовызываемых функций/вызовов может положительно повлиять на производительность.

Но системные вызовы в случае kprobes – это не особо интересно, когда мы можем перехватить почти любую функцию ядра:
spec:
kprobes:
call: "tcp_connect" syscall: false

return: true
Это почти аналог первого примера, но тут мы перехватываем только TCP-соединения уже на уровне ядра, в начале статьи был упомянут интерфейс io_uring, для которого первый пример с системным вызовом не позволит перехватить активность, а второй с функцией ядра перехватит TCP-соединение приложения использующего io_uring для сетевой активности. С одной стороны теперь мониторинг не видит установку соединений отличных от TCP, но в то же время позволяет детектировать использование продвинутых техник, которые злоумышленники могут использовать для обхода аудита. Следующий рывок в глубину кода ядра приводит нас к LSM-хуку, который будет вызываться в случае системного вызова connect:
spec:
kprobes:
call: "security_socket_connect" syscall: false
return: true
В этом случае мы будем ловить любые вызовы connect вне зависимости от типа сокета в том числе подключение к Unix-сокету. И тут как раз стоит вспомнить о фильтрации в рамках политики, которую мы разберем позже, так как не все виды соединений нам нужны и имеет смысл ограничить мониторинг только определенным видом сокетов, например AF_INET и AF_INET6, которые соответствуют IPv4 и IPv6 соединениям. На тех узлах, где используются UNIX-сокеты, имеет смысл производить аудит и их.

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

В самом начале мы поставили две цели: не использовать системные вызовы, так как они не совсем надёжны, а также получать информацию о всех попытках монтирования, как успешных, так и неудачных. В результате получили примерных кандидатов на выбор:
Ошибка
sys_mount
do_mount
path_mount
security_sb_mount
no device
+
-
+
+
wrong option
+
-
+
+
no mount point
+
-
-
-
Практически сразу список покинула функция do_mount, так как вызов не проходил через неё в дистрибутиве Debian, причина была не ясна, поэтому надо выбрать другую функцию, но и тут существует момент, что остальные функции ядра не обрабатывают ситуацию с ошибкой "no mount point", что не позволит нам увидеть все вызовы монтирования. В результате пришлось отойти от первначальных планов и выбрать в качестве функции для привязки системный вызов mount. Да, существуют риски с использованием системных вызовов, но пришлось выбрать такое решение. Не исключаем, что оно может быть пересмотрено в будущем, но в определенном моменте пришлось делать такой выбор.

Tracepoints

Один из самых надёжных механизмов для мониторинга ядра, так как tracepoints не так часто меняются, поэтому если вам важна стабильность работы политик на разных ядрах, то следует обратить внимание на них. Из минусов – мониторить можно только те участки ядра, где эти самые Tracepoints расставлены.

LSM BPF

Позволяет поключиться к LSM-хукам ядра, через которые проходят вызовы других функций и системных вызовов. Актуально использовать этот механизм, если по тем или иным причинам перехват через kprobes запрещён, либо они в принципе отключены в ядре.

Uprobes

Забавный факт: можно считать uprobes очень далёкой причиной появления этой статьи, так как первичный материал для неё был собран в ходе экспериментов с этой привязкой, так как тема не совсем раскрыта в документации Tetragon. Первая попытка подойти к снаряду в итоге не получилась: примеры из документации не работали, не получалось собрать минимально работающую политику, так как Tetragon всё время ругался при её загрузке.
Со второй попытки удалось нащупать путь к хоть какому-нибудь использованию uprobes: получилось создать загружающуюся политику, подключиться к процессу и перехватить функцию внутри кода этого процесса. Впрочем радость сменилась горьким разочарованием по следующим причинам:
  • материала по использованию uprobes всё ещё мало, да ещё и есть сомнение в его актуальности, так как примеры в этих статьях не совсем работают;
  • отсюда другая существенная проблема – код внутри приложений меняется чаще, чем у ядра, что приводит к тому, что становится трудно подобрать универсальную точку внедрения в процесс для получения нужных данных;
  • при загрузке политики мы ничего не знаем о приложении, его версии и прочих ключевых моментах, которые нужно указать в загружаемой политике;
  • другая существенная проблема – составление самой политики, так как область работы ограничена пространством пользователя хостовой системы, соответственно, чтобы иметь возможность подключиться к процессу в контейнере, это происходит через дикие "костыли";
  • в текущем виде поддержка uprobes в Tetragon не совсем жизнеспособна, требуется динамическое средство, которое будет для выбранного процесса генерировать политику;
  • дополнительно ситуацию осложняет момент поддержки Tetragon нужных структур, точнее отсутствие этой поддержки в случае uprobes, фактически мы можем работать только с примитивами вроде строк и целочисленных значений, когда ПО оперирует классами и прочими сложными структурами;
  • также Tetragon не поддерживает получение возвращаемого значения функции в случае uprobes, плюс не работает значительная часть фильтров в рамках селектора;
  • намного проще получать с нужного приложения его журнал через syslog/journald, что уже поддержано во многих системах безопасности, не стоит скатываться в фанатизм, пытаясь реализовать всё в Tetragon.
В итоге от начальных планов перехвата SQL-запросов в СУБД MariaDB пришлось отказаться и была выбрана более простая цель для выработки PoC (Proof of Concept). Для начала была избрана функция readline в приложении bash:
apiVersion: cilium.io/v1alpha1 kind: TracingPolicy
metadata:
name: "bash-readline" spec:
uprobes:
path: "/procRoot/3762319/root/bin/bash" symbols:
"readline" args:
index: 0 type: "string"
По сравнению с привычным uprobes, тут у нас для привязки используется путь к исполняемому файлу (path) и название функции внутри кода приложения (symbols). Путь к файлу задан таким странным образом, чтобы получить доступ к процессу из контейнера, "/procRoot" в данном случае просто специфический способ оборначение файловой системы "/proc" внутри Tetragon, дальше идёт идентификатор процесса и уже через директорию root в этом пути происходит доступ к исполняемому файлу. Аргумент у этой функции ровно один в виде массива байн, но нам проще получать его в виде строки.

Ожидание, что мы сейчас перехватим вводимую в bash команду, что же мы получим на самом деле (пример события сокращён):
"path": "/procRoot/3762319/root/bin/bash", "symbol": "readline",
"policy_name": "bash",

"args": [
{
"string_arg": "root@test-pod-debian-mount:/# "
}
Но в итоге в событие приветствие командной строки, на самом деле команда должна быть в возвращаемом значении функции, но как уже указано раньше, получить мы его в рамках Tetragon не можем. Давайте искать какую-нибудь функцию рядом, чтобы там была команда:
apiVersion: cilium.io/v1alpha1 kind: TracingPolicy
metadata:
name: "bash-readline" spec:
uprobes:
path: "/procRoot/3762319/root/bin/bash" symbols:
"xrealloc" args:
index: 0 type: "string"
В качестве такой функции будет xrealloc, её сложно назвать идеальным кандидатом, но для PoC подойдёт, правда помимо самой команды мы получим немного мусора в виде приветствия командной строки, но в одном из событий будет то, ради чего это всё затевалось:
"path": "/procRoot/3762319/root/bin/bash", "symbol": "xrealloc",
"policy_name": "bash", "args": [
{
"string_arg": "whoami"
}
Наконец-то мы достигли цели и получили введённую команду, но какой ценой. Пожалуй, всё же не совсем стоящей приложенных усилий.
Блок аргументов и возвращаемого значения функции

Так как для аудита могут быть полезны не все аргументы функции или системного вызова, то в этом блоке политики можно перечислить нужные, также здесь указывается возвращаемое значение функции, если оно нужно. Формат блока достаточно простой:
spec:
kprobes:

call: "security_file_permission" syscall: false
return: true args:
index: 0 type: "file"
index: 1 type: "int"
returnArg: index: 0 type: "int"
Через директиву index указывается порядковый номер аргумента и дальше через директиву type тип аргумента, которых существует определенное количество.

Возвращаемое значение функции имеет тот же формат, но указывается немного иначе, так как возвращаемое значение только одно.

В версии Tetragon 1.4.0 появилась очень полезная функция – теперь можно через специальную директиву resolve получать значение нужного поля из структуры. Выглядит это следующим образом
spec:
kprobes:
call: "register_kprobe" syscall: false
return: false args:
index: 0
resolve: "symbol_name" type: "string"
В данном случае мы получаем из структуры kprobe поле symbol_name, как видно из синтаксиса, нам не нужно указывать само название структуры, а только путь в её пределах до нужного поля.

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

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

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

Давайте более подробно поговорим об ограничениях. В одной политике не допускается:

  • более 8 селекторов;
  • не более 5 фильтров matchArgs в одном селекторе (подразумевается поддержка не более 5 аргументов);
  • в каждом селекторе может быть не более 4 значений идентификаторов процесса в фильтре matchPIDs;

Фильтры будут перечислены по мере их полезности в плане отсеивания лишних событий. Остальные будут перечислены после них.
matchArgs

Один из основных фильтров, используемых в политиках, отсеивает события по значению конкретного аргумента. Именно в этом блоке подразумевается производить отсев большей части событий, основными критериями станут пути к файлам, IP-адреса и порты и прочие сущности системы. В случае с файловой политикой слишком общие пути к файлам (например /usr, /var и так далее) приведут к шквалу событий способному положить почти любую систем, с другой стороны для сетевой политики вы можете не знать нужных адресов и портов, либо они могут быть в достаточно широком значений.



matchBinaries

Фильтр событий на основании пути к исполняемому файлу. Тут стоит отдельно обратить внимание, что значение поля binary в событии и фактический путь к исполняемому файлу могут существенно отличаться, не стоит ориентироваться на первый и имеет смысл уточнить путь к файлу, игнорируя всевозможные символические ссылки на файл или директории, в рамках которых он находится.

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

В то же самое время мы можем создать список отслеживаемых процессов, этакий черный список. В этом случае можно использовать операторы In, Postfix, которым нужен либо путь к файлу, либо имя файла соответственно.

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



matchNamespaces

Tetragon умеет на уровне самой политики привязываться к определенному неймспейсу Kubernetes по его названию, что позволяет получать события только из нужных подов, этот подход и рекомендуется для эффективного получения только нужных событий. Однако может получиться, что в некоторых случаях такой вариант не подходит, отсюда есть алтернатива – фильтрация по неймспейсам Linux.

Трудность использования этой политики, что нам нужно знать численное значение неймспейса, чтобы выделить события конкретных подов. Отсюда имеет смысл использовать этот фильтр только для отбрасывания событий, которые происходят в хостовой системе, либо чтобы мониторить конкретные события в этой самой хостовой системе. Если мы хотим исключить события хостовой системы, тогда нам нужно использовать оператор NotIn и специальное значение host_ns, для удобства можно выбрать неймспейс Pid. Если же нам нужны события из хоста без событий контейнеров, то для неймспейса Pid используем оператор In и значение host_ns.



matchActions

Эту директиву сложно назвать фильтром, но с её помощью можно производить определенные действия. Сам блок является массивом объектов, поэтому можно указать несколько методов, разберем самые полезные:

  • метод Post отвечает за пересылку события в пользовательское пространство другой части Tetragon, самым интересным моментом тут является опция rateLimit, которая позволяет ограничивать число событий в момент времени, то есть если указать 3m, то событие будет генерироваться только раз в 3 минуты. Это помогает в тех политиках, где перехватываемая функция/вызов генерируют много дубликатов. Существует противоположный метод NoPost, который соответственно не передаёт событие дальше.
  • метод Sigkill и Signal позволяют отправить процессу, который сгенерировал событие, соответствующий сигнал, можно рассматривать как вариант энфорса, но стоит помнить, что Kubernetes будет пытаться запустить всё назад.
  • метод Override позволяет переопределить возвращаемое значение, основной смысл имеет в случае LSM-хуков, когда можно поменять вердикт в нужных ситуациях.





matchPIDs

Менее полезный фильтр, так как мы не можем предугадать значения идентификаторов процессов. В общем виде эту историю можно использовать только для тех процессов, которые имеют постоянный PID, например, init. В таком случае за счёт операторов In или NotIn мы можем либо получать события Init, либо исключать их из потока.



matchReturnArg

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



matchReturnActions

Действие аналогично matchActions, только выполняться этот блок будет по совпадению в matchReturnArg



matchCapabilities

Фильтрация по значению Linux Capabilities – достаточно сомнительный фильтр для оптимизации объема событий. Плюс не в каждой политике требуется фильтрация по Capabilities. Конкретных рекомендаций сложно дать.



matchNamespaceChanges

Фильтр отсеивает события, у которых меняется конкретные Linux Namespaces. Также сомнительно для уменьшения числа событий, да и является очень нишевым фильтром, который будет очень редко использоваться в политиках.

matchCapabilitiesChanges

Фильтр отсеивает события, у которых меняется конкретные Linux Capabilities. Также сомнительно для уменьшения числа событий, да и является очень нишевым фильтром, который будет очень редко использоваться в политиках.

Открыть часть 1. Tetragon: лучшие практики и нюансы разработки Tracing Policy

2026-02-26 08:23