
Привет! Меня зовут Виталий Шишкин, я эксперт продукта Container Security в Positive Technologies. За годы работы над продуктом MaxPatrol 10 мы строили аудит Linux на базе подсистемы Auditd, которая решала свою задачу и достаточно просто настраивалась, но ситуация поменялась с появлением контейнеров, которые Auditd корректно поддерживать не умеет. Поэтому эта задача потребовала не просто смену решения для аудита системы, но и создание целого продукта, который сможет учитывать особенности Kubernetes и используемые им технологии ядра Linux.
Подсистема auditd закрывала большую часть основных потребностей в аудите безопасности многие годы, альтернативой ей были проприетарные решения от вендоров ИБ. Мы также опирались на формат сообщений auditd в решениях классов SIEM и EDR, поэтому для него за эти годы накоплено много правил и экспертизы, которая актуальна и используется до сих пор.
Ситуация изменилась, когда Linux стал основой для платформы контейнеризации приложений сначала в виде Docker, а потом уже и в виде оркестратора Kubernetes. Сразу появилась задача обеспечения безопасности с учетом специфики подобных сред, но сам auditd не был готов к этому, хотя разработчики этой подсистемы знают о проблеме и прорабатывают ее решение (см. issue в репозитории auditd), а системы нужно защищать «еще вчера», потребность в этом высока.
Поэтому заинтересованные лица пошли другим путем, развивая технологию eBPF в ядре Linux и используя ее как основу для мониторинга и аудита системы. Сама технология далеко не нова и уходит корнями в 90-е годы, когда появилась технология BPF (Berkeley Packet Filter) — виртуальная машина на уровне сетевого стека для работы с сетевым трафиком. В ядре Linux эту идею улучшили и расширили (e означает extended), предоставляя широкий доступ к сущностям ядра. На базе eBPF с момента его создания появилось значительное число решений, начиная от обеспечения сетевой связанности и заканчивая мониторингом ресурсов и аудитом безопасности. В нашем случае для продукта PT Container Security для защиты контейнерных сред, нужен был готовый eBPF-движок, который позволит гибко и эффективно обеспечивать мониторинг защищаемых активов, так как решать эти задачи средствами auditd невозможно. И такой движок нашелся — Tetragon.
Сразу определим основные цели статьи:
демонстрация возможностей по уменьшению числа событий до минимально необходимого;
обзор возможностей Tetragon при составлении политик и возможные ограничения.
В идеале всегда хочется получать от системы максимум событий — как событий аудита, так и обычных событий журналов приложений, относящихся к безопасности. Но на практике этому мешает множество факторов:
создаваемая событиями нагрузка на систему нарушает доступность или SLA сервиса;
в рамках инфраструктуры могут существовать и другие источники событий (Windows, сетевые устройства, приложения, системы безопасности и т. п.), с которыми придется делить общее место;
принимающая события система мониторинга может не справляться с таким потоком;
требования внутренних нормативных актов или регуляторов к хранению истории событий создают дополнительную проблему их архивирования и хранения, особенно в плане ресурсов накопителей;
необходимость оставлять запас производительности, чтобы добавлять новые политики мониторинга.
В результате актуальной становится задача оптимизации числа событий, сведения его к минимально необходимому для вып��лнения задач информационной безопасности, и этот процесс можно назвать бесконечным, так как, помимо оптимизации старых источников событий, будут появляться новые. В статье будет сделан акцент на процессе этой оптимизации. Стоит отметить, что существует определенный предел, который ее ограничит.
Почему мы используем Tetragon
Одна из основных возможностей в нашем продукте для защиты контейнерных сред — это защита рантайма (среды выполнения) контейнеров в кластерах Kubernetes от вредоносной активности злоумышленников. В отличие от классических узлов с Linux это требует учета контекста сущностей Kubernetes, а также связи с используемыми для этого сущностями ядра Linux в виде Linux namespaces, cgroups и других механизмов. Для этого нужно либо своими силами создать свой eBPF-движок, либо использовать готовые и проверенные решения: Tracee, Falco, KubeArmor и т. п. Из всех этих решений мы выбрали компонент Tetragon, который разработан в рамках проекта Cilium. Для этого есть множество причин:
Tetragon обогащает события аудита контекстом Kubernetes и другими связанными сущностями. Falco и Tracee умеют делать подобное, но добавляют избыточный контекст, который не всегда нужен и влияет на производительность.
Tetragon не содержит лишнего, он минималистичен, в случае других решений мы будем тащить в защищаемую систему лишние компоненты, которые могут создать дополнительную поверхность атаки либо конфликтовать с другими решениями ИБ, вызывая сбои.
Механизм Tracing Policies, который предлагает Tetragon, позволяет выбирать, что и как отслеживать в системе, без необходимости писать код; другие решения для расширения своей функциональности требуют знания языка того или иного программирования, который они используют, предоставляя пользователю только возможность вносить незначительные изменения в работу имеющихся модулей, что сильно ограничивает возможности.
Согласно данным тестов, имеющимся в свободном доступе, Tetragon имеет самую низкую задержку при генерации события. Иными словами, он самый быстрый среди конкурентов. Естественно, это зависит от качества разработанных для него политик, но уже хорошо, что он не вносит лишних искажений.
Специфичный для нашего продукта плюс: он, также как и Tetragon, написан на Go, что позволяет не нести в продукт лишних зависимостей, а также в некоторых случаях позволяет разработчикам продукта выявлять в Tetragon баги и сообщать о них сообществу, которое его развивает.
Это, однако, не освобождает от минусов, которые у Tetragon тоже есть:
Tetragon активно развивается, что приводит к досадным ошибкам. Так и случилось в версии 1.4.0 (см. issue и дубликат), когда в ходе подготовки релиза не были учтены некоторые изменения, что привело к искажению отдельных полей в событиях. Позже это было исправлено в релизе 1.4.1, но такие проблемы не единичны и периодически всплывают новые (см. issue).
Также Tetragon может не поддерживать нужные структуры, которые используются в перехватываемых функциях ядра, это приводит к тому, что приходится ждать доработок либо дорабатывать код самостоятельно, прежде чем появится возможность отслеживать определенные угрозы. Начиная с версии 1.4.0 появилась возможность через директиву resolve получать любое поле из структуры аргумента, но это не всегда решает проблему. Кроме того, пока нет возможности получить несколько полей из одной структуры.
Отдельные вопросы есть к документации, она скудная, альтернативных материалов немного, примеры из документации могут не работать в принципе, приходится проводить многочисленные эксперименты, чтобы понять, как правильно работать с Tetragon.
Некоторые функции (например, uprobes) не особо развиваются и предлагают низкий уровень поддержки.
Но это все не кажется существенной проблемой на фоне того, что проверенное решение в виде auditd в принципе не поддерживает контейнеры, хотя является достаточно зрелым и документированным. Да, мы не можем переиспользовать контент, и нам потребуется начать с условного нуля в случае Tetragon, но это уже что-то, и обеспечивать безопасность контейнеров с ним можно сегодня, а не в неопределенном будущем. Да, auditd предоставлял удобный и высокоуровневый интерфейс для аудита, а с Tetragon придется работать с ядром на достаточно низком уровне, что потребует больших компетенций, но с другой стороны, это даже плюс, так как развивать их в любом случае нужно: атаки становятся сложнее, и старые решения их просто не видят.
Поэтому мы и решили сделать эту статью, чтобы на примерах продемонстрировать основные возможности по оптимизации политик в сторону генерации меньшего числа событий, а также отчасти восполнить тот недостаток документации, с которым сталкиваются начинающие свое знакомство с Tetragon.
Поиск функции ядра и общий анализ безопасности
Но прежде чем разбирать составление и оптимизацию самой политики, стоит рассмотреть основные вопросы, связанные с предметом мониторинга, так как Tetragon поддерживает разные сущности ядра и от этого зависит как надежность данных в событии, так и количество событий. Сам процесс поиска нужной функции приводить не будем: это потребует отдельной статьи. Здесь же выделим основные вопросы, которые вы должны поставить перед собой, прежде чем составлять политику:
насколько вам нужна защита от продвинутых атак и способов обхода аудита системы?
важно ли вам наличие возвращаемого значения функции или системного вызова?
важно ли вам получать не только успешные попытки вызова функции или нужны еще и неуспешные?
сколько производительности узла вы готовы отдать под аудит безопасности?
есть ли у вас альтернативные средства мониторинга, которые специализируются на конкретных атаках (например, сетевых)?
поддерживает ли Tetragon нужные структуры, используемые в функции ядра, системном вызове, LSM-функции?
Дело в том, что достаточно давно аудит системы можно было производить через подсистему auditd, но очень ограниченно — только в отношении файловых операций и системных вызовов. Последние особо уязвимы для перехвата и замены на модифицированные, а также существуют альтернативные интерфейсы для работы в обход системных вызовов, к примеру тот же интерфейс io_uring. Файловые операции также уязвимы для некоторых атак, которые позволяют обойти правила мониторинга. До появления eBPF и инструментов на базе этой технологии существовала возможность разработки модуля ядра, работающего с подсистемой LSM (Linux Security Modules), которая имеет на вооружении LSM-хуки, через которые проходят функции ядра, прежде чем получить возможность совершить действие. Некоторые вендоры использовали этот подход в своих продуктах и получали преимущество над другими в плане качественного покрытия аудита системы. Tetragon же позволяет более глубоко работать с внутренностями ядра Linux, поэтому можно производить качественный мониторинг без необходимости собирать модуль ядра. Ему также доступны LSM-хуки или любые другие функции ядра за счет использования механизма kprobes; кроме того, можно использовать более привычные варианты вроде работы с системными вызовами или трейспоинтами ядра, но с учетом, что их возможности ограничены и существуют риски.
Также существуют нюансы, связанные с возвращаемым значением функции, потому что те же функции ядра не слишком хорошо документированы, чуть лучше ситуация с LSM-хуками, так как они предназначены для разрешения или ограничения доступа к ресурсам, поэтому ситуация с документацией у них лучше. Меньше всего проблем с возвращаемыми значениями у системных вызовов, так как они являются официальным интерфейсом для программирования в ОС, поэтому хорошо документированы, а также играют роль своеобразной точки входа, поэтому в теории любой вызов в ОС должен пройти через них.
И тут мы двигаемся к третьему вопросу, так как именно перехват системного вызова может дать информацию обо всех попытках его использования. Чем ниже мы двигаемся в своеобразной лестнице вызовов внутрь ядра, тем больше теряем информации, поэтому для того же LSM-хука не все вызовы могут дойти, так как будут отброшены либо промежуточной функцией ядра, либо другим LSM-хуком, который вы не собирались использовать, поскольку он вам не нужен либо не подходит для мониторинга. С одной стороны, это не особо важно, но в некоторых случаях может не соответствовать вашим целям.
И вот тут мы можем вспомнить о производительности. В идеале существует желание производить аудит любого действия в системе, но это технически невозможно, так как счет их идет на десятки и сотни тысяч в секунду и миллионы в минуту. Следует просто принять тот факт, что всю систему мониторить невозможно, поэтому нужно определить особо важные зоны и работать по ним. Некоторые из них также могут выпасть из аудита по причинам производительности. Одна из таких зон — сетевое взаимодействие, еще при работе с auditd в нашей практике приходилось отключать мониторинг сети полностью, что может показаться не очень приемлемым решением, но тут важно, что альтернативные решения, связанные с отказом от мониторинга файловой активности, еще хуже, так как теряется еще больше информации о происходящем в системе.
Плюс существуют отдельные решения для анализа сетевой активности, которые выполняют эту работу лучше, чем если пытаться делать подобное средствами auditd или даже всемогущего Tetragon (или другого eBPF-решения). NTA-системы позволяют не только анализировать трафик с нескольких узлов, что дает более полную картину, но и разбирать сетевой трафик до самых высокоуровневых слоев сетевой модели. Также они могут ловить атаки, которые не характерны для одной ОС и направлены против другой ОС, либо сетевого сервиса, либо даже отдельного приложения. Кажется бессмысленным детектировать на Linux-узле атаку в сторону Active Directory, поэтому злоумышленник без проблем ее запустит с этого узла и не будет зафиксирован, а вот NTA-система увидит такую атаку и укажет, откуда она произошла, что даст основу для дальнейших действий.
Понимаю, что весь этот поток вопросов может вызывать ступор, но именно с них и нужно начать работу, так как это будет отправной точкой при создании политики, которой в итоге может и не быть, если в начале пути возникнут проблемы с выбором точки, через которую будет производиться мониторинг. В рамках статьи мы опустим вопрос выбора и для удобства будем использовать уже устоявшиеся для конкретных случаев объекты для аудита. Тут стоит отдельно отметить, что они хоть и выбраны «правильно», но требуют осторожного обращения, ибо если эти объекты аудита не ограничивать, то это приведет к увеличению числа событий, способному «положить» систему даже когда она предположительно ничего не делает, так как сервисы и процессы, обеспечивающие ее работоспособность, на самом деле ведут активность в фоновом режиме, что породит события, которые в свою очередь создадут новые события и так до полного поглощения всех ресурсов системы.
Недоступные для перехвата функции
К упомянутой проблеме поиска нужной функции стоит отдельно отметить саму возможность перехвата этой функции средствами, предусмотренными в Linux. Скорее всего, эта информация вам не понадобится, но стоит знать этот шаг для того, чтобы не терять время на отладку и поиск решения. Если ядро Linux запрещает перехват конкретной функции, то при попытке это сделать вы получите такое сообщение в журнале ядра:
trace_kprobe: Could not probe notrace function register_ftrace_function
В данном случае даже через самый универсальный метод в виде kprobes мы не можем перехватить функцию register_ftrace_function, и это в самом деле так, поскольку она имеет в ядре метку notrace. Но есть более простой способ проверить возможность перехвата функции без необходимости изучать исходный код ядра. Достаточно выполнить команду:
grep register_ftrace_function /sys/kernel/debug/tracing/available_filter_functions
Тут мы получим пустое значение, так как в этом списке доступных к перехвату функций ее нет. Если же функцию можно перехватить, то мы увидим ее в этом списке:
root@k8s-sn:~# grep security_file_permission /sys/kernel/debug/tracing/available_filter_functions
security_file_permission
Функция security_file_permission доступна для перехвата.
Список доступных для перехвата функций зависит от некоторых опций ядра, которые сложно перечислить в полном объеме. Кроме того, в определенных версиях ядра некоторые функции могут быть запрещены к перехвату уже на уровне кода этого ядра. Поэтому в рамках нашей работы производится проверка выбранной функции на всех LTS-ядрах, которые поддерживаются на тот момент.
Виды политик
Как мы уже упомянули раньше, работа Tetragon настраивается через механизм так называемых Tracing Policies. Это YAML-документ с определенным набором директив, которые и будут описывать, что это политика будет перехватывать и как дальше работать с этой информацией, чтобы получить итоговое событие аудита.
У Tetragon существует два вида политик:
глобальная;
namespaced-политика с привязкой к неймспейсу Kubernetes.
С глобальной все понятно: эта политика будет применяться для генерации событий в рамках всей системы; второй вид ограничивает генерацию событий контейнерами конкретного неймспейса Kubernetes. Помимо этого, оба вида политик поддерживают через определенные директивы ограничение генерации событий до конкретного пода или контейнера. То есть уже на этапе выбора вида будущей политики можно существенно ограничить объем событий, если использовать namespaced-политики, откидывая ненужные неймспейсы Kubernetes, а заодно и события хостовой ОС.
Тут стоит отметить, что эти два вида политик фактически представлены двумя разными ресурсами в Kubernetes, что означает возможность использовать одинаковое имя для глобальной и namespaced-политики. Также в рамках последней неймспейс Kubernetes является элементом имени, поэтому можно создать политику с одним и тем же именем в разных неймспейсах. С другой стороны, это может вызвать путаницу, так как в самом событии имя политики будет отображаться без неймспейса, поскольку это отдельное поле, которым Tetragon обогащает событие, не говоря уже о возможности отключить добавление этой информации в событии. Поэтому рекомендуется все же давать политикам понятные и полные имена, чтобы было меньше проблем с поиском политики, которая сгенерировала событие.
Основные блоки политики
Для лучшего понимания разделим политику на несколько крупных блоков, в каждом из них может быть один или несколько подблоков, которые чаще обновляются в плане синтаксиса. Перечисленные ниже крупные блоки обновляются значительно реже и их вполне можно считать более постоянными. Стоит отметить, что подобное деление на блоки и подблоки — это не терминология самого Tetragon, а упрощение в рамках этой статьи: так проще воспринимать, чем стандартный YAML-документ, используемый в рамках Kubernetes, которым и является политика. Итак, три основных блока политики будут следующими (подблоков здесь не будет и их легче разобрать в отдельных разделах):
сервисные блоки — по большей части содержат стандартизированный заголовок политики и ее метаинформацию;
опциональные блоки — всевозможные необязательные блоки, которые используются в специфических случаях или задают дополнительные опции для Tetragon;
блок привязки — основной блок, где указывается тип привязки и перехватываемый объект, а также всевозможные фильтры для отбора событий перехватываемого объекта.
Минимальная политика будет состоять из сервисного блока и блока привязки, последний из которых является ключевым и задает все параметры для мониторинга. Опциональный блок, как и говорит его название, чаще всего будет отсутствовать и применяться только в конкретных случаях. Давайте пройдем по каждому блоку и разберем его подблоки.
Сервисные блоки
Заголовок
На данный момент у нас существует два вида политик, поэтому здесь будет также два варианта заголовков, в зависимости от типа ресурса Kubernetes. Глобальная выглядит следующим образом:
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
Namespaced-политика имеет следующий заголовок:
apiVersion: cilium.io/v1alpha1
kind: TracingPolicyNamespaced
Тут, в принципе, все понятно, поэтому для получения списка политик через API Kubernetes придется запрашивать оба типа ресурсов, чтобы получить список всех политик.
Метаинформация
В этом блоке указываются некоторые сервисные поля, которые использует Tetragon для идентификации или привязки к неймспейсу (для namespaced-политик).
Поля:
name — название политики;
namespace — неймспейс Kubernetes, к которому будет привязана политика (только для namespaced-политик).
Лучше это будет показать на примере namespaced-политики:
metadata:
name: "test-policy"
namespace: "default"
Соответственно, у глобальной политики тут будет заполнено только название. В примере выше namespaced-политика с названием test-policy будет привязана к неймспейсу default.
Опциональные блоки
Опции
Дальше после директивы spec начинается сама спецификация политики, которая содержит как обязательные блоки, без которых политика просто не загрузится, так и множество опциональных. Раздел опций — один из таких блоков. Скорее всего, он практически никогда не пригодится, но стоит помнить, что он есть. Каждое значение в этом блоке задается через пару «ключ—значение», соответствующую полям name и value, то есть примерный вид подобного блока может быть таким:
spec:
options:
- name: "disable-kprobe-multi"
value: "1"
Здесь мы для опции disable-kprobe-multi устанавливаем значение 1. То есть отключаем возможность устанавливать множественные kprobes.
Селектор пода
Еще один опциональный блок, с помощью которого можно распространить действие политики только на конкретные поды. Делается это на основании блока меток labels у пода, это стоит учитывать, так как не всегда он может быть заполнен. Изначально был доступен только один способ сопоставления меток — matchLabels, но позже был добавлен более универсальный matchExpressions. Разберем каждый из них.
Директива matchLabels производит полное сравнение значения метки, что соответствует оператору In у директивы matchExpressions; сама метка указывается как ключ, то есть этот блок состоит из пар «ключ—значение». Таких пар может быть несколько, но при выборке они объединяются логическим И, то есть у искомого пода должны быть заполнены и соответствовать все метки. Пример:
spec:
podSelector:
matchLabels:
app: "test-pod-debian"
В данном случае политика будет применена ко всем подам, у которых метка app имеет значение test-pod-debian.
Директива matchExpressions делает почти то же самое, но позволяет использовать другие операторы сравнения. В качестве примера проще будет переписать предыдущий блок, чтобы объяснить различие, но в то же время добавим еще одно значение, чтобы показать различие с предыдущим вариантом:
spec:
podSelector:
matchExpressions:
- key: "app"
operator: "In"
values:
- "test-pod-debian"
- "test-pod-ubuntu"
Теперь значений может быть несколько, тут действует логическое ИЛИ, то есть значение метки app должно соответствовать одному из указанных. Но это еще не все, теперь помимо проверки соответствия значения мы можем через оператор NotIn делать противоположную операцию:
spec:
podSelector:
matchExpressions:
- key: "app"
operator: "NotIn"
values:
- "test-pod-debian"
- "test-pod-ubuntu"
Теперь уже искомые поды не должны иметь в метке перечисленные значения. Также теперь мы можем проверять на наличие определенной метки, массив значений в таком случае должен быть пустым:
spec:
podSelector:
matchExpressions:
- key: "app"
operator: "Exist"
В этом случае будут выбраны поды, у которых есть метка app. Как и в случае с предыдущей парой операторов, тут также можно сделать отрицание условия:
spec:
podSelector:
matchExpressions:
- key: "app"
operator: "DoesNotExist"
Теперь будут выбраны поды, у которых нет метки app.
За счет этого механизма можно выбирать конкретные поды для мониторинга в политике, что уменьшит число событий.
Селектор контейнера
Когда требуется еще более гранулярная выборка. Работает по аналогии с podSelector, только изначально для контейнеров использовалась директива matchExpressions, для выборки поддерживается пока только название контейнера:
spec:
containerSelector:
matchExpressions:
- key: "name"
operator: "In"
values:
- "db"
- "nginx"
То есть ищем контейнеры с названием db или nginx, можно сделать и наоборот:
spec:
containerSelector:
matchExpressions:
- key: "name"
operator: "NotIn"
values:
- "db"
- "nginx"
Теперь выбираем контейнеры, которые не носят название db или nginx.
Блоки lists и enforcers
Существуют для специфического случая, когда нет поддержки множественных kprobes, тогда в lists перечисляются нужные системные вызовы, в enforcers указывается этот список, а также указывается метод NotifyEnforcer в фильтре селектора matchActions. Пример из документации:
spec:
lists:
- name: "dups"
type: "syscalls"
values:
- "sys_dup"
- "sys_dup2"
enforcers:
- calls:
- "list:dups"
tracepoints:
- subsystem: "raw_syscalls"
event: "sys_enter"
args:
- index: 4
type: "syscall64"
selectors:
- matchArgs:
- index: 0
operator: "InMap"
values:
- "list:dups"
matchBinaries:
- operator: "In"
values:
- "/usr/bin/bash"
matchActions:
- action: "NotifyEnforcer"
argSig: 9
Так как все это реализуется через трейспоинты ядра, то это нельзя назвать надежным вариантом, потому что, как и с любым доступным вне ядра интерфейсом вроде системных вызовов, существуют техники обхода через другие механизмы перехвата с помощью атак типа TOCTOU.
Блок привязки
Основной блок логики политики, который отвечает за саму привязку политики к одной из поддерживаемых Tetragon сущностей. От выбора типа привязки зависит многое, так как некоторые из них нацелены на достаточно нишевые сущности. Можно разделить имеющиеся привязки на основные и дополнительные, первые могут активно использоваться для мониторинга, вторые предназначены для нишевых случаев, которые используются достаточно редко. Из основных привязок стоит выделить следующие:
kprobes — основной тип привязки через механизм ядра kprobes, умеет работать как с функциями ядра и LSM-хуками, так и с системными вызовами, поэтому максимально универсальный;
tracepoints — позволяет осуществлять мониторинг через расставленные в ядре трейспоинты, менее универсален чем kprobes, но также может использоваться, дополнительно умеет мониторить системные вызовы;
lsmhooks — позволяет работать с LSM-хуками; с одной стороны, это существенно ограничивает число мест для перехвата, но с другой — через подсистему LSM должны проходить все вызовы, поэтому эта привязка является достаточно универсальным механизмом.
И дополнительные виды привязок:
uprobes — позволяет мониторить процессы в пользовательском пространстве, но есть множество нюансов в использовании этого механизма;
usdts — аналогично uprobes позволяет мониторить процессы в пользовательском пространстве, но только при наличии специально заданных при сборке приложения точек отладки (версия 1.6.0 с поддержкой этого вида привязки вышла уже после финализации контента статьи).
В одной политике может быть только один блок привязки, так как от этого зависит формат генерируемой eBPF-программы.
Kprobes
Механизм kprobes — универсальный способ перехвата практически любой функции в ядре (кроме функций, реализующих сам механизм kprobes, и некоторых других вроде do_page_fault и notifier_call_chain, а также тех, что имеют метку notrace), начиная от 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», что не позволит нам увидеть все вызовы монтирования. В результате пришлось отойти от первначальных планов и выбрать в качестве функции для привязки системный вызов sys_mount. Так мы получим все попытки монтирования, но потеряем в безопасности. Считаем это решение вынужденным, но допустимым. Если в будущем будут подходящие альтернативы, то переделаем политику. Это показывает, что не всегда самый очевидный способ будет лучшим на практике, обстоятельства могут сыграть злую шутку и заставить использовать не особо оптимальный или безопасный вариант. В том числе само ядро Linux или Tetragon будут не только помогать вам в решении проблем, но и создавать новые. К этому просто надо быть готовым.
Tracepoints
Один из самых надежных механизмов для мониторинга ядра, поскольку tracepoints не так часто меняются, поэтому если вам важна стабильность работы политик на разных ядрах, то следует обратить внимание на них. Из минусов: мониторить можно только те участки ядра, где эти самые tracepoints расставлены. Еще можно работать с системными вызовами через этот механизм. То есть блок для системного вызова connect будет выглядеть следующим образом:
spec:
tracepoints:
- subsystem: "raw_syscalls"
event: "sys_connect"
LSM BPF
Позволяет подключиться к LSM-хукам ядра, через которые проходят вызовы других функций и системных вызовов. Этот механизм актуален, если по тем или иным причинам перехват через kprobes запрещен либо они в принципе отключены в ядре. Аналогичная kprobes конфигурация для LSM BPF будет выглядеть следующим образом:
spec:
lsmhooks:
- hook: "socket_connect"
То есть мы выбрасываем префикс security_ и получаем нужное название LSM-хука. Как вы можете заметить, тут нет опций syscall и return, и если с первой понятно, то вторая подразумевает, что в случае LSM BPF мы не можем получать возвращаемое значение функции ни в событии, ни в самой политике, то есть фильтр matchReturnArgs не поддерживается для привязки типа lsmhooks. Возможно, это не такая большая проблема, но в некоторых случаях возвращаемое значение понадобится. Следует учитывать этот момент.
Uprobes
Использование uprobes существенно отличается от всех других привязок, которые мы разобрали выше. Легче будет привести полную политику и объяснить ее смысл:
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"
В данном случае нам нужно указать путь к исполняемому файлу в директиве path, который выглядит очень странно, но на самом деле все проще: /procRoot — это путь до файловой системы /proc, но через особый алиас, который использует Tetragon, все остальное — это путь к данным процесса и, уже в рамках этой сущности, путь к исполняемому файлу. В symbols указываем имя функции, блок с аргументами почти не отличается от аналогичных в других случаях. В этом случае я приведу пример события, чтобы объяснить, для чего это все делалось:
{
"path":"/procRoot/3762319/root/bin/bash",
"symbol":"xrealloc",
"policy_name":"bash",
"args":[
{
"string_arg":"whoami"
}
]
}
В результате мы получили в одном из событий команду, выполненную в Bash. Логика работы с uprobes и другими похожими средствами чем-то напоминает работу с ядром, но там есть свои особенности и серьезные ограничения. Лучше будет рассказать это в отдельной статье, когда функциональность uprobes получит существенные доработки в Tetragon.
Блок аргументов и возвращаемого значения функции
Так как для аудита могут быть полезны не все аргументы функции или системного вызова, в этом блоке политики можно перечислить нужные; также здесь указывается возвращаемое значение функции, если оно нужно. Формат блока достаточно простой:
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 при поддержке нужного аргумента, поэтому вы получите сырое значение, которое дальше нужно будет сопоставлять с предопределенными значениями, если они есть. Лучше всего это будет видно для сетевых соединений, где через целочисленное значение передаются типы сокетов и протоколов:
{
"function_name":"tcp_connect",
"args":[
{
"sock_arg":{
"family":"AF_INET",
"type":"SOCK_STREAM",
"protocol":"IPPROTO_TCP",
"mark":0,
"priority":0,
"saddr":"127.0.0.1",
"daddr":"127.0.0.1",
"sport":48804,
"dport":8888,
"cookie":"18446616644469922368",
"state":"TCP_SYN_SENT"
},
"label":""
}
]
}
В данном случае Tetragon сам производит преобразование целочисленных значений полей family и type в текстовые строки AF_INET и SOCK_STREAM. Что будет, если мы попробуем получить эти значения через resolve:
spec:
kprobes:
- call: "tcp_connect"
syscall: false
return: false
args:
- index: 0
resolve: "__sk_common.skc_family"
type: "int16"
Тут всплывает еще один минус resolve: пока есть возможность получить через него только одно поле. Получим family:
{
"function_name":"tcp_connect",
"args":[
{
"int_arg":2
}
]
}
В принципе, мы его получили, потому что AF_INET имеет значение 2 в десятичном виде, но выглядит это не очень удобно по сравнению с результатом, который дает Tetragon без resolve. Так что пока эта функциональность полезна лишь частично и нужна только в крайнем случае, если необходимо получить одно неподдерживаемое поле.
Селекторы
Основной блок, где происходит выборка событий за счет использования определенных фильтров, каждый из которых отсеивает события по определенному критерию.
Каждый селектор является элементом последовательности в терминологии YAML, то есть должен предваряться дефисом. Нужно особо строго следить за этим, так как любой лишний дефис может создать лишний селектор, что поменяет логику работы политики, а также может привести к ошибке при загрузке политики, так как существует ограничение на число селекторов в политике.
Давайте более подробно поговорим об ограничениях в рамках одной политики:
не более 8 селекторов;
не более 5 фильтров matchArgs в одном селекторе (подразумевается поддержка не более 5 аргументов);
в каждом селекторе может быть не более 4 значений идентификаторов процесса в фильтре matchPIDs.
Фильтры будут перечислены по убыванию их полезности в плане отсеивания лишних событий. Остальные будут перечислены после.
Полезные фильтры
matchArgs
Один из основных фильтров, используемых в политиках, отсеивает события по значению конкретного аргумента. Именно в этом блоке можно производить отсев большей части событий, основными критериями станут пути к файлам, IP-адреса и порты и прочие сущности системы. В случае с файловой политикой слишком общие пути к файлам (/usr, /var и тому подобные) приведут к шквалу событий, способному «положить» почти любую систему; с другой стороны, для сетевой политики вы можете не знать нужных адресов и портов либо они могут быть в достаточно широком диапазоне значений. Тем не менее приведем несколько примеров.
Совпадение пути по префиксу:
- matchArgs:
- index: 0
operator: "Prefix"
values:
- "/etc/pam"
Совпадение пути по постфиксу:
- matchArgs:
- index: 0
operator: "Postfix"
values:
- ".profile"
Можно также фильтровать сетевые события по типу протокола:
- matchArgs:
- index: 0
operator: "Family"
values:
- "AF_INET"
- "AF_INET6"
Операторов много, и со временем добавляются новые.
matchBinaries
Фильтр событий на основании пути к исполняемому файлу. Тут стоит отдельно обратить внимание, что значение поля binary в событии и фактический путь к исполняемому файлу могут существенно различаться, не стоит ориентироваться на первое значение, и имеет смысл уточнить путь к файлу, игнорируя всевозможные символические ссылки на файл или каталог, в рамках которого он находится.
В некоторых случаях имеет смысл фильтровать события от определенных процессов, которые являются легитимными, то есть составить своего рода белый список. В этом помогут операторы NotIn, NotPostfix, первый подразумевает полный путь к файлу, второй может содержать только имя файла:
- matchBinaries:
- operator: "NotIn"
values:
- "/usr/bin/kubelet"
В то же время мы можем создать список отслеживаемых проце��сов, своего рода черный список. В этом случае можно использовать операторы In, Postfix, которым нужен либо путь к файлу, либо имя файла соответственно.
- matchBinaries:
- operator: "Postfix"
values:
- "nmap"
Тут стоит отдельно отметить, что подобная фильтрация может использоваться злоумышленниками для сокрытия своей активности, так как мы не можем привязаться к файлу и его характеристикам, поэтому если в политике фильтруются процессы по имени исполняемого файла, то вредоносное ПО может мимикрировать под него.
matchNamespaces
Tetragon умеет на уровне самой политики привязываться к определенному неймспейсу Kubernetes по его названию, что позволяет получать события только из нужных подов, этот подход и рекомендуется для эффективного получения только нужных событий. Однако может получиться, что в некоторых случаях такой вариант не подходит, поэтому есть альтернатива — фильтрация по неймспейсам Linux.
Трудность использования этой политики в том, что нам нужно знать численное значение неймспейса, чтобы выделить события конкретных подов. Поэтому имеет смысл использовать этот фильтр только для отбрасывания событий, которые происходят в хостовой системе, либо чтобы мониторить конкретные события в самой этой хостовой системе. Если мы хотим исключить события хостовой системы, тогда нам нужно использовать оператор NotIn и специальное значение host_ns, для удобства можно выбрать неймспейс Pid:
- matchNamespaces:
- namespace: Pid
operator: NotIn
values:
- "host_ns"
Если же нам нужны события из хоста без событий контейнеров, то для неймспейса Pid используем оператор In и значение host_ns:
- matchNamespaces:
- namespace: Pid
operator: In
values:
- "host_ns"
matchActions
Эту директиву сложно назвать фильтром, но с ее помощью можно производить определенные действия. Сам блок является массивом объектов, поэтому можно указать несколько методов. Разберем самые полезные:
метод Post отвечает за пересылку события в пользовательское пространство другой части Tetragon. Самой интересной тут является опция rateLimit, которая позволяет ограничивать число событий за период времени, то есть если указать 3m, то событие будет генерироваться только раз в 3 минуты. Это помогает в тех политиках, где перехватываемая функция или вызов генерируют много дубликатов. Существует противоположный метод NoPost, который, соответственно, не передает событие дальше;
методы Sigkill и Signal позволяют отправить процессу, который сгенерировал событие, сигнал завершения процесса или другой поддерживаемый сигнал соответственно (можно рассматривать как вариант энфорса, но стоит помнить, что Kubernetes будет пытаться запустить все назад);
метод Override позволяет переопределить возвращаемое значение, основной смысл имеет в случае LSM-хуков, когда можно в некоторых ситуациях поменять вердикт.
В качестве примера приведем ограничение событий через rateLimit:
- matchActions:
- action: Post
rateLimit: "3m"
matchPIDs
Менее полезный фильтр, так как мы не можем предугадать значения идентификаторов процессов. В общем виде его можно использовать только для тех процессов, которые имеют постоянный PID, например для init. В таком случае за счет операторов In или NotIn мы можем либо получать события init, либо исключать их из потока.
- matchPIDs:
- operator: "NotIn"
values:
- "1"
Остальные фильтры
matchReturnArg
Так как задача получения возвращаемого значения функции стоит не всегда, есть сомнение в полезности фильтрации по этому значению. С какой-то степенью уверенности можно привязываться к возвращаемых значениям системных вызовов, возвращаемые значения функций ядра не слишком хорошо описаны, поэтому данный фильтр очень нишевый, да и не может служить для существенного уменьшения событий с узла.
matchReturnActions
Действие аналогично matchActions, только выполняться этот блок будет по совпадению в matchReturnArg.
matchCapabilities
Фильтрация по значению Linux capabilities — достаточно сомнительный фильтр для оптимизации объема событий. Плюс не в каждой политике требуется фильтрация по capabilities. Конкретные рекомендации дать сложно.
matchNamespaceChanges
Фильтр отсеивает события, у которых меняются конкретные Linux namespaces. Также сомнительно для уменьшения числа событий, является очень нишевым фильтром, который редко будет использоваться в политиках.
matchCapabilitiesChanges
Фильтр отсеивает события, у которых меняется конкретные Linux capabilities. Также сомнительно для уменьшения числа событий, нишевый фильтр, редко используется в политиках.
Вывод по селекторам
Как мы видим особенно полезных фильтров для отсечения лишних событий тут два — по аргументам функции и по пути до исполняемого файла. В некоторых случаях могут помочь фильтры неймспейсов либо действия.
Заключение
С одной стороны, возможности по оптимизации потока событий у Tetragon не поражают воображение, и кто-нибудь может ожидать более высокоуровневых сущностей для фильтрации (например, доменного имени для сетевых событий). С другой стороны, нет дополнительных задержек для получения подобных сущностей, что положительно влияет на производительность.
Дополнительные материалы
Таблица типов аргументов
Примерная таблица типов аргументов функций. Сами аргументы могут быть как примитивами (строка, число и т. п.), так и структурными. Учитывая, что Tetragon по-своему работает со структурными типами, не все поля типа могут быть в событии, для фильтрации поддерживается еще меньше полей.
Название |
Тип |
Описание |
Релиз |
Размер в байтах |
auto |
Служебный |
Автоматический выбор подходящего типа аргумента |
1.0.0 |
— |
bpf_attr |
Структурный |
Аргументы, используемые при загрузке eBPF-программы |
0.8.1 |
— |
bpf_cmd |
Примитив |
Перечисление с командами eBPF |
1.3.0 |
— |
bpf_map |
Структурный |
Структура, описывающая eBPF Maps, используемые для обмена |
0.8.3 |
— |
cap_effective |
Структурный |
Наследник kernel_cap_t, описывающий эффективные capabilities |
1.1.0 |
— |
cap_inheritable |
Структурный |
Наследник kernel_cap_t, описывающий наследуемые capabilities |
1.1.0 |
— |
cap_permitted |
Структурный |
Наследник kernel_cap_t, описывающий разрешенные capabilities |
1.1.0 |
— |
capability |
Структурный |
Структура, представляющая Linux capability |
0.8.3 |
— |
char_buf |
Примитив |
Массив символов |
0.8.0 |
— |
char_iovec |
Примитив |
Массив для работы с векторными операциями ввода-вывода |
0.8.0 |
— |
cred |
Структурный |
Структура, содержащая все разрешения приложения |
0.11.0 |
— |
data_loc |
Примитив |
Для tracepoints, в таком виде они представляют строки |
1.1.0 |
— |
dentry |
Структурный |
Узел в иерархии файловой системы |
1.4.0 |
— |
fd |
Примитив |
Файловый дескриптор, в плане значений аналогично int32 |
0.8.0 |
4 |
file |
Структурный |
Структура, представляющая файл |
0.8.0 |
— |
filename |
Структурный |
Структура, представляющая имя файла |
0.8.0 |
— |
int |
Примитив |
32-битное знаковое целочисленное значение, синоним int32 |
0.8.0 |
4 |
int8 |
Примитив |
8-битное знаковое целочисленное значение |
1.1.0 |
1 |
int16 |
Примитив |
16-битное знаковое целочисленное значение |
1.1.0 |
2 |
int32 |
Примитив |
32-битное знаковое целочисленное значение |
0.8.0 |
4 |
int64 |
Примитив |
64-битное знаковое целочисленное значение |
0.8.0 |
8 |
iov_iter |
Структурный |
Итератор для работы со структурой iovec |
0.10.0 |
— |
kernel_cap_t |
Структурный |
Структура, хранящая строку с Linux capabilities |
1.1.0 |
— |
kiocb |
Структурный |
Структура для асинхронного ввода-вывода |
0.10.0 |
— |
linux_binprm |
Структурный |
Структура для хранения аргументов при загрузке исполняемого файла |
1.1.0 |
— |
load_info |
Структурный |
Структура, используемая при загрузке модуля ядра |
1.0.0 |
— |
module |
Структурный |
Представление модуля ядра |
1.0.0 |
— |
net_device |
Структурный |
Структура, описывающая сетевое устройство |
1.1.0 |
— |
nop |
Неизвестно |
Неизвестно |
0.8.0 |
— |
path |
Структурный |
Относительный путь |
0.8.0 |
— |
perf_event |
Структурный |
Представление события о производительности в ядре |
0.8.1 |
— |
size_t |
Примитив |
Поле, хранящее результат sizeof, в плане значений аналогично uint32 |
0.8.0 |
4 |
skb |
Структурный |
Буфер сокета |
0.8.0 |
— |
sock |
Структурный |
Представление сокета на сетевом уровне |
0.8.0 |
— |
sockaddr |
Структурный |
Структура, описывающая адрес сокета |
1.4.0 |
— |
socket |
Структурный |
Представление сокета BSD |
1.4.0 |
— |
string |
Примитив |
Нуль-терминированная строка |
0.8.0 |
— |
syscall64 |
Служебный |
Для работы с системными вызовами через tracepoint |
1.1.0 |
— |
uint8 |
Примитив |
8-битное беззнаковое целочисленное значение |
1.1.0 |
1 |
uint16 |
Примитив |
16-битное беззнаковое целочисленное значение |
1.1.0 |
2 |
uint32 |
Примитив |
32-битное беззнаковое целочисленное значение |
0.8.0 |
4 |
uint64 |
Примитив |
64-битное беззнаковое целочисленное значение |
0.8.0 |
8 |
user_namespace |
Структурный |
Структура, представляющая пользовательский Linux namespace |
0.8.3 |
— |
Список является примерным, плюс не все типы аргументов могут встретиться в ходе работы.
Для удобства примеры политики и событий будут расположены в репозитории по ссылке: https://github.com/vshishkin38/2025-11-tetragon-examples