Давайте, прежде чем приступить к экспериментам, определим требования к системе наблюдения за процессами:
- Логироваться должны сведения обо всех процессах, даже о короткоживущих.
- У нас должны быть сведения о полном пути к исполняемому файлу для всех запущенных процессов.
- У нас, в пределах разумного, не должно возникать необходимости в модификации или перекомпиляции нашего кода для разных версий ядра.
- Дополнительное требование: если хост-система является узлом Kubernetes или использует Docker, то у нас должна быть возможность определить то, к какому именно поду/контейнеру принадлежит процесс. Для этого обычно достаточно знать
cgroup ID
процесса. Дело в том, что с точки зрения ядра нет такого понятия, как «контейнер» или «идентификатор контейнера». Ядро оперирует лишь такими понятиями, как «контрольные группы», «сетевые пространства имён», «пространства имён процессов», оно работает с различными независимыми API, с помощью которых средства контейнеризации вроде Docker реализуют механизмы контейнеризации. Если попытаться идентифицировать контейнеры посредством ID уровня ядра, нужен уникальный идентификатор контейнера. В случае с Docker данному требованию удовлетворяют идентификаторы контрольных групп.
Поговорим об обычных API Linux, которые могут помочь в решении этой задачи. Мы, чтобы не усложнять повествование, уделим особое внимание процессам, создаваемым с помощью системных вызовов execve
. Если же говорить о более полном решении задачи, то при его реализации нужно, кроме того, мониторить процессы, созданные с помощью системных вызовов fork/clone
и их вариантов, а так же — результаты работы вызовов execveat
.
Простые решения, реализуемые в пользовательском режиме
- Обращение к
/proc
. Этот способ, из-за проблемы короткоживущих процессов, нам не особенно подходит. - Использование netlink. Механизмы netlink позволят получать уведомления о короткоживущих процессах, но в этих уведомлениях будут содержаться лишь числовые данные наподобие
PID
процессов. Сведений о пути к исполняемому файлу процесса там не будет. В результате придётся возвращаться к чтению данных из/proc
, сталкиваясь со сложностями при работе с короткоживущими процессами. - Использование подсистемы аудита Linux. Это — лучший способ решения нашей задачи, реализуемый в пользовательском режиме. Подходящие API имеются во всех современных ядрах. Они дают сведения о полном пути к исполняемому файлу процесса и не пропускают короткоживущие процессы. У этого метода есть лишь два недостатка. Первый — это то, что в некий момент времени лишь одна программа, работающая в пользовательском режиме, может взаимодействовать с подсистемой аудита. Это превращается в большую проблему в том случае, если некто занимается разработкой решений в области информационной безопасности для организаций и при этом некоторые клиенты используют API аудита сами, применяя
auditd
илиosquery
. Средства мультиплексирования событий, работающие в пользовательском режиме, вродеauditd
и go-audit, в теории, могут смягчить эту проблему. Но в случае с решениями корпоративного класса нельзя заранее знать о том, используют ли клиенты подобные средства, а если используют — то какие именно. Нельзя заранее знать и о том, какие именно средства для обеспечения безопасности, работающие напрямую с API аудита, применяются клиентами. Второй недостаток заключается в том, что API аудита ничего не знают о контейнерах. И это — несмотря на то, что данный вопрос обсуждается уже много лет.
Простые средства отладки, работающие в режиме ядра
Реализация этих механизмов предусматривает использование «датчиков» (probe) различных типов в единственном экземпляре.
▍Точки трассировки
Использование точек трассировки (tracepoint
). Точки трассировки — это датчики, статически включённые в определённые места ядра во время его компиляции. Каждый такой датчик можно включить независимо от других, в результате чего он будет выдавать уведомления в тех случаях, когда достигается то место кода ядра, в которое он внедрён. Ядро содержит несколько подходящих нам точек трассировки, код которых выполняется в различные моменты работы системного вызова execve
. Это — sched_process_exec
, open_exec
, sys_enter_execve
, sys_exit_execve
. Для того чтобы получить этот список, я выполнил команду cat /sys/kernel/tracing/available_events | grep exec
и отфильтровал полученный список, пользуясь сведениями, полученными в ходе чтения кода ядра. Эти точки трассировки подходят нам лучше, чем вышеописанные механизмы, так как они позволяют организовать наблюдение за короткоживущими процессами. Но ни одна из них не даёт информацию о полном пути к исполняемому файлу процесса в том случае, если параметрами exec
является относительный путь к такому файлу. Другими словами, если пользователь выполняет команду вроде cd /bin && ./ls
, тогда мы получим сведения о пути в виде ./ls
, а не в виде /bin/ls
. Вот простой пример:
# Включение точки трассировки the sched_process_exec sudo -s cd /sys/kernel/debug/tracing echo 1 > events/sched/sched_process_exec/enable # запуск ls с использованием относительного пути cd /bin && ./ls # получение данных из точки трассировки sched_process_exec # обратите внимание на то, что сведений о полном пути у нас нет cd - cat trace | grep ls # отключение точки трассировки echo 0 > events/sched/sched_process_exec/enable
▍Датчики kprobe/kretprobe
Датчики kprobe
позволяют извлекать отладочную информацию практически из любого места ядра. Они подобны особым точкам останова в коде ядра, которые выдают информацию, но при этом не останавливают выполнение кода. Датчик kprobe
, в отличие от точек трассировки, можно подключить к самым разным функциям. Код такого датчика сработает в процессе выполнения системного вызова execve
. Но я не нашёл в графе вызовов execve
ни одной функции, параметрами которой является и PID
процесса, и полный путь к его исполняемому файлу. В результате тут мы сталкиваемся с той же «проблемой относительных путей» что и при использовании точек трассировки. Тут можно, опираясь на особенности конкретного ядра, кое-что «подкрутить». В конце концов, датчики kprobe
могут считывать данные из стека вызовов ядра. Но такое решение не будет стабильно работать в разных версиях ядра. Поэтому его я его не рассматриваю.
▍Использование eBPF-программ с точками трассировки, с датчиками kprobe и kretprobe
Тут речь идёт о том, что при выполнении некоего кода будут срабатывать точки трассировки или датчики, но при этом будет выполняться код eBPF-программ, а не код обычных обработчиков событий.
Использование этого подхода открывает перед нами некоторые новые возможности. Теперь мы можем запускать в ядре произвольный код при выполнении системного вызова execve
. Это, теоретически, должно дать нам возможность извлекать из ядра любую необходимую нам информацию и отправлять её в пользовательское пространство. Есть два способа получения подобных данных, но ни один из них не соответствует вышеописанным требованиям.
- Можно читать сведения из структур данных ядра, вроде
task_struct
илиlinux_binprm
. Собственно говоря, это позволит нам узнать полный путь к исполняемому файлу процесса, но чтение информации из структур данных ядра делает нас зависимыми от версии ядра. Например, можно поместить точку трассировки вsched_process_exec
и, воспользовавшись в eBPF-программе ограниченным циклом, обойти цепочку структур dentry вbprm->file->f_path.dentry
, отправляя в пользовательское пространство по одному фрагменту данных за раз с применением кольцевого буфера. Нашей eBPF-программе нужно знать смещения членов структуры. Поэтому её нужно компилировать для разных версий ядра с использованием соответствующих заголовков. Обычно подобные задачи решают, компилируя eBPF-программы во время выполнения кода, но у такого подхода есть свои проблемы, вроде требования наличия заголовков ядра на каждом компьютере, где планируется компилировать программы. - Можно воспользоваться вспомогательными функциями eBPF для получения данных из ядра. Такой подход обеспечивает совместимость с разными версиями ядра, содержащими используемые вспомогательные функции. Тут мы не работаем со структурами данных ядра напрямую — вместо этого для получения нужных данных используются вспомогательные API. У этого подхода есть лишь одна проблема — не существует вспомогательных функций для получения сведений о полном пути к исполняемому файлу процесса. (Правда, в современных версиях ядер имеется вспомогательная функция eBPF, которая позволяет получить
cgroup ID
, а это помогает в деле сопоставления процессов и контейнеров).
«Хакерские» решения
- Использование
LD_PRELOAD
для каждого запускаемого исполняемого файла и перехватexec
вlibc
. Сразу скажу, что делать этого я не рекомендую. Это решение не подойдёт для статически компилируемых исполняемых файлов, его использование снижает уровень защищённости системы от вредоносного кода, оно, к тому же, подразумевает довольно грубое вмешательство в работу системы. - Применение точек трассировки для
execve
,fork/clone
иchdir
для того чтобы наблюдать не только за созданием процессов, но и получать сведения об их текущих рабочих директориях. Речь идёт о том, что для каждого вызоваexecve
нужно будет находить рабочую директорию процесса и комбинировать эти данные с параметрамиexecve
для получения полного пути. Если вы решите реализовать именно этот механизм — то позаботьтесь об использовании eBPF-мэппинга и поместите всю логику в eBPF-программу для того чтобы избежать состояния гонок в том случае, если события приходят в пользовательское пространство в неправильном порядке. - Решения, основанные на
ptrace
. Эти решения слишком грубы для использования их в продакшн-коде. Но если вы воспользуетесь именно этими механизмами — применяйтеptrace
вместе сseccomp
и с флагомSECCOMP_RET_TRACE
. Это позволитseccomp
перехватывать все системные вызовыexecve
в ядре и передавать их в отладчик пользовательского пространства, который может логировать сведения о вызовахexecve
и после этого сообщатьseccomp
о возможности продолжения обычной работы сexecve
. - Использование AppArmor. Можно написать профиль AppArmor, который запретит процессам вызывать выполнение исполняемых файлов. Если воспользоваться этим профилем в режиме обучения (complain), то AppArmor не запретит выполнение процессов, а лишь будет выдавать уведомления о нарушениях правил, заданных в профиле. Если подключать профиль к каждому выполняющемуся процессу, то мы получим работающее, но весьма непривлекательное и слишком «хакерское» решение. Вероятно, пользоваться этим подходом не стоит.
Другие решения
Сразу скажу, что ни одно из этих решений не соответствует нашим требованиям, но, всё же, перечислю их:
- Использование утилиты
ps
. Этот инструмент просто обращается к/proc
и, в результате, страдает от тех же проблем, что и прямое обращение к/proc
. - Применение новой версии execsnoop, основанной на eBPF. Это, по сути, решение, основанное на
kprobe/kretprobe
, поэтому оно так же зависит от версий ядра, как и подобные решения, описанные выше. Кроме того,execsnoop
не даёт информации о полных путях к исполняемым файлам, а значит, применяя этот механизм, мы ничего не выигрываем. - Использование старой версии
execsnoop
, не поддерживающей eBPF. Это — обычный датчикkprobe
, поэтому нам это не подходит.
Решения из будущего
В будущем можно будет воспользоваться недоступной пока вспомогательной eBPF-функцией get_fd_path. После того, как она будет добавлена в ядро, она пригодится для решения нашей задачи. Правда, полный путь к исполняемому файлу процесса придётся получать, используя способ, не предусматривающий чтение информации из структур данных ядра.
Итоги
Ни один из рассмотренных API не идеален. Ниже я хочу дать несколько рекомендаций относительно того, какими именно подходами стоит пользоваться для получения информации о процессах, и о том, когда ими стоит пользоваться:
- Если можете — пользуйтесь механизмами аудита с помощью
auditd
или go-audit. Это позволит получать сведения обо всех процессах, включая короткоживущие. Вы, кроме того, не прилагая никаких усилий, получите сведения о полных путях к исполняемым файлам процессов. Это решение, правда, не будет работать в том случае, если кто-то уже использует API аудита с помощью инструментов, работающих в пространстве пользователя, отличающихся от ваших. Если вы столкнулись с подобной проблемой — возможно, вам подойдут следующие рекомендации. - Если полные пути к исполняемым файлам вас не интересуют, и вам, при этом, нужно решить задачу как можно быстрее и проще, не занимаясь написанием некоего кода, рекомендую взглянуть на
execsnoop
. Единственный минус этого подхода — необходимость в доступе к заголовкам ядра во время выполнения кода. - Если вам, опять же, не интересны полные пути к исполняемым файлам, но при этом вы готовы немного потрудиться ради того, чтобы избежать зависимости от заголовков ядра, тогда можете воспользоваться вышеописанными точками трассировки. К ним можно подключиться множеством различных способов, есть много подходов к передаче их данных в пространство пользователя. Например, тут применим показанный выше интерфейс файловой системы, eBPF-программы с eBPF-мэппингом, утилита
perf
. Рассказ обо всём этом достоин отдельной статьи. Самое главное, о чём стоит помнить, выбирая этот способ наблюдения за процессами, заключается в следующем. Если вы пользуетесь eBPF-программами — проконтролируйте возможность их статической компиляции, что позволит вам не зависеть от заголовков ядра. А ведь именно этой зависимости мы и пытаемся избежать, применяя этот метод. Применение этого метода, кроме того, означает невозможность работы со структурами данных ядра и невозможность использования фреймворков вроде BCC, компилирующих программы eBPF во время выполнения кода. - Если вас не интересуют короткоживущие процессы и предыдущие рекомендации вам не подходят — воспользуйтесь возможностями netlink совместно с
/proc
.
Как вы организуете наблюдение за работающими процессами в Linux?
ссылка на оригинал статьи https://habr.com/ru/company/ruvds/blog/525830/
Добавить комментарий