О сложностях мониторинга работающих процессов в Linux

О сложностях мониторинга работающих процессов в Linux
О сложностях мониторинга работающих процессов в Linux

Все знают о том, как наблюдать за работающими процессами в Linux-системе. Но почти никто не добивается в подобных наблюдениях высокой точности. На самом деле, всем методам мониторинга процессов, о которых пойдёт речь в этом материале, чего-то не хватает.

Давайте, прежде чем приступить к экспериментам, определим требования к системе наблюдения за процессами:

  1. Логироваться должны сведения обо всех процессах, даже о короткоживущих.
  2. У нас должны быть сведения о полном пути к исполняемому файлу для всех запущенных процессов.
  3. У нас, в пределах разумного, не должно возникать необходимости в модификации или перекомпиляции нашего кода для разных версий ядра.
  4. Дополнительное требование: если хост-система является узлом Kubernetes или использует Docker, то у нас должна быть возможность определить то, к какому именно поду/контейнеру принадлежит процесс. Для этого обычно достаточно знать cgroup ID процесса. Дело в том, что с точки зрения ядра нет такого понятия, как «контейнер» или «идентификатор контейнера». Ядро оперирует лишь такими понятиями, как «контрольные группы», «сетевые пространства имён», «пространства имён процессов», оно работает с различными независимыми API, с помощью которых средства контейнеризации вроде Docker реализуют механизмы контейнеризации. Если попытаться идентифицировать контейнеры посредством ID уровня ядра, нужен уникальный идентификатор контейнера. В случае с Docker данному требованию удовлетворяют идентификаторы контрольных групп.

Поговорим об обычных API Linux, которые могут помочь в решении этой задачи. Мы, чтобы не усложнять повествование, уделим особое внимание процессам, создаваемым с помощью системных вызовов execve. Если же говорить о более полном решении задачи, то при его реализации нужно, кроме того, мониторить процессы, созданные с помощью системных вызовов fork/clone и их вариантов, а так же — результаты работы вызовов execveat.

Простые решения, реализуемые в пользовательском режиме

  1. Обращение к /proc. Этот способ, из-за проблемы короткоживущих процессов, нам не особенно подходит.
  2. Использование netlink. Механизмы netlink позволят получать уведомления о короткоживущих процессах, но в этих уведомлениях будут содержаться лишь числовые данные наподобие PID процессов. Сведений о пути к исполняемому файлу процесса там не будет. В результате придётся возвращаться к чтению данных из /proc, сталкиваясь со сложностями при работе с короткоживущими процессами.
  3. Использование подсистемы аудита Linux. Это — лучший способ решения нашей задачи, реализуемый в пользовательском режиме. Подходящие API имеются во всех современных ядрах. Они дают сведения о полном пути к исполняемому файлу процесса и не пропускают короткоживущие процессы. У этого метода есть лишь два недостатка. Первый — это то, что в некий момент времени лишь одна программа, работающая в пользовательском режиме, может взаимодействовать с подсистемой аудита. Это превращается в большую проблему в том случае, если некто занимается разработкой решений в области информационной безопасности для организаций и при этом некоторые клиенты используют API аудита сами, применяя auditd или osquery. Средства мультиплексирования событий, работающие в пользовательском режиме, вроде auditd и go-audit, в теории, могут смягчить эту проблему. Но в случае с решениями корпоративного класса нельзя заранее знать о том, используют ли клиенты подобные средства, а если используют — то какие именно. Нельзя заранее знать и о том, какие именно средства для обеспечения безопасности, работающие напрямую с API аудита, применяются клиентами. Второй недостаток заключается в том, что API аудита ничего не знают о контейнерах. И это — несмотря на то, что данный вопрос обсуждается уже много лет.

Простые средства отладки, работающие в режиме ядра

Реализация этих механизмов предусматривает использование «датчиков» (probe) различных типов в единственном экземпляре.

▍Точки трассировки

Использование точек трассировки (tracepoint). Точки трассировки — это датчики, статически включённые в определённые места ядра во время его компиляции. Каждый такой датчик можно включить независимо от других, в результате чего он будет выдавать уведомления в тех случаях, когда достигается то место кода ядра, в которое он внедрён. Ядро содержит несколько подходящих нам точек трассировки, код которых выполняется в различные моменты работы системного вызова execve. Это — sched_process_execopen_execsys_enter_execvesys_exit_execve. Для того чтобы получить этот список, я выполнил команду cat /sys/kernel/tracing/available_events | grep exec и отфильтровал полученный список, пользуясь сведениями, полученными в ходе чтения кода ядра. Эти точки трассировки подходят нам лучше, чем вышеописанные механизмы, так как они позволяют организовать наблюдение за короткоживущими процессами. Но ни одна из них не даёт информацию о полном пути к исполняемому файлу процесса в том случае, если параметрами exec является относительный путь к такому файлу. Другими словами, если пользователь выполняет команду вроде cd /bin && ./ls, тогда мы получим сведения о пути в виде ./ls, а не в виде /bin/ls. Вот простой пример:

 

▍Датчики kprobe/kretprobe

Датчики kprobe позволяют извлекать отладочную информацию практически из любого места ядра. Они подобны особым точкам останова в коде ядра, которые выдают информацию, но при этом не останавливают выполнение кода. Датчик kprobe, в отличие от точек трассировки, можно подключить к самым разным функциям. Код такого датчика сработает в процессе выполнения системного вызова execve. Но я не нашёл в графе вызовов execve ни одной функции, параметрами которой является и PID процесса, и полный путь к его исполняемому файлу. В результате тут мы сталкиваемся с той же «проблемой относительных путей» что и при использовании точек трассировки. Тут можно, опираясь на особенности конкретного ядра, кое-что «подкрутить». В конце концов, датчики kprobe могут считывать данные из стека вызовов ядра. Но такое решение не будет стабильно работать в разных версиях ядра. Поэтому его я его не рассматриваю.

▍Использование eBPF-программ с точками трассировки, с датчиками kprobe и kretprobe

Тут речь идёт о том, что при выполнении некоего кода будут срабатывать точки трассировки или датчики, но при этом будет выполняться код eBPF-программ, а не код обычных обработчиков событий.

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

  1. Можно читать сведения из структур данных ядра, вроде task_struct или linux_binprm. Собственно говоря, это позволит нам узнать полный путь к исполняемому файлу процесса, но чтение информации из структур данных ядра делает нас зависимыми от версии ядра. Например, можно поместить точку трассировки в sched_process_exec и, воспользовавшись в eBPF-программе ограниченным циклом, обойти цепочку структур dentry в bprm->file->f_path.dentry, отправляя в пользовательское пространство по одному фрагменту данных за раз с применением кольцевого буфера. Нашей eBPF-программе нужно знать смещения членов структуры. Поэтому её нужно компилировать для разных версий ядра с использованием соответствующих заголовков. Обычно подобные задачи решают, компилируя eBPF-программы во время выполнения кода, но у такого подхода есть свои проблемы, вроде требования наличия заголовков ядра на каждом компьютере, где планируется компилировать программы.
  2. Можно воспользоваться вспомогательными функциями eBPF для получения данных из ядра. Такой подход обеспечивает совместимость с разными версиями ядра, содержащими используемые вспомогательные функции. Тут мы не работаем со структурами данных ядра напрямую — вместо этого для получения нужных данных используются вспомогательные API. У этого подхода есть лишь одна проблема — не существует вспомогательных функций для получения сведений о полном пути к исполняемому файлу процесса. (Правда, в современных версиях ядер имеется вспомогательная функция eBPF, которая позволяет получить cgroup ID, а это помогает в деле сопоставления процессов и контейнеров).

«Хакерские» решения

  1. Использование LD_PRELOAD для каждого запускаемого исполняемого файла и перехват exec в libc. Сразу скажу, что делать этого я не рекомендую. Это решение не подойдёт для статически компилируемых исполняемых файлов, его использование снижает уровень защищённости системы от вредоносного кода, оно, к тому же, подразумевает довольно грубое вмешательство в работу системы.
  2. Применение точек трассировки для execvefork/clone и chdir для того чтобы наблюдать не только за созданием процессов, но и получать сведения об их текущих рабочих директориях. Речь идёт о том, что для каждого вызова execve нужно будет находить рабочую директорию процесса и комбинировать эти данные с параметрами execve для получения полного пути. Если вы решите реализовать именно этот механизм — то позаботьтесь об использовании eBPF-мэппинга и поместите всю логику в eBPF-программу для того чтобы избежать состояния гонок в том случае, если события приходят в пользовательское пространство в неправильном порядке.
  3. Решения, основанные на ptrace. Эти решения слишком грубы для использования их в продакшн-коде. Но если вы воспользуетесь именно этими механизмами — применяйте ptrace вместе с seccomp и с флагом SECCOMP_RET_TRACE. Это позволит seccomp перехватывать все системные вызовы execve в ядре и передавать их в отладчик пользовательского пространства, который может логировать сведения о вызовах execve и после этого сообщать seccomp о возможности продолжения обычной работы с execve.
  4. Использование AppArmor. Можно написать профиль AppArmor, который запретит процессам вызывать выполнение исполняемых файлов. Если воспользоваться этим профилем в режиме обучения (complain), то AppArmor не запретит выполнение процессов, а лишь будет выдавать уведомления о нарушениях правил, заданных в профиле. Если подключать профиль к каждому выполняющемуся процессу, то мы получим работающее, но весьма непривлекательное и слишком «хакерское» решение. Вероятно, пользоваться этим подходом не стоит.

Другие решения

Сразу скажу, что ни одно из этих решений не соответствует нашим требованиям, но, всё же, перечислю их:

  1. Использование утилиты ps. Этот инструмент просто обращается к /proc и, в результате, страдает от тех же проблем, что и прямое обращение к /proc.
  2. Применение новой версии execsnoop, основанной на eBPF. Это, по сути, решение, основанное на kprobe/kretprobe, поэтому оно так же зависит от версий ядра, как и подобные решения, описанные выше. Кроме того, execsnoop не даёт информации о полных путях к исполняемым файлам, а значит, применяя этот механизм, мы ничего не выигрываем.
  3. Использование старой версии execsnoop, не поддерживающей eBPF. Это — обычный датчик kprobe, поэтому нам это не подходит.

Решения из будущего

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

Итоги

Ни один из рассмотренных API не идеален. Ниже я хочу дать несколько рекомендаций относительно того, какими именно подходами стоит пользоваться для получения информации о процессах, и о том, когда ими стоит пользоваться:

  1. Если можете — пользуйтесь механизмами аудита с помощью auditd или go-audit. Это позволит получать сведения обо всех процессах, включая короткоживущие. Вы, кроме того, не прилагая никаких усилий, получите сведения о полных путях к исполняемым файлам процессов. Это решение, правда, не будет работать в том случае, если кто-то уже использует API аудита с помощью инструментов, работающих в пространстве пользователя, отличающихся от ваших. Если вы столкнулись с подобной проблемой — возможно, вам подойдут следующие рекомендации.
  2. Если полные пути к исполняемым файлам вас не интересуют, и вам, при этом, нужно решить задачу как можно быстрее и проще, не занимаясь написанием некоего кода, рекомендую взглянуть на execsnoop. Единственный минус этого подхода — необходимость в доступе к заголовкам ядра во время выполнения кода.
  3. Если вам, опять же, не интересны полные пути к исполняемым файлам, но при этом вы готовы немного потрудиться ради того, чтобы избежать зависимости от заголовков ядра, тогда можете воспользоваться вышеописанными точками трассировки. К ним можно подключиться множеством различных способов, есть много подходов к передаче их данных в пространство пользователя. Например, тут применим показанный выше интерфейс файловой системы, eBPF-программы с eBPF-мэппингом, утилита perf. Рассказ обо всём этом достоин отдельной статьи. Самое главное, о чём стоит помнить, выбирая этот способ наблюдения за процессами, заключается в следующем. Если вы пользуетесь eBPF-программами — проконтролируйте возможность их статической компиляции, что позволит вам не зависеть от заголовков ядра. А ведь именно этой зависимости мы и пытаемся избежать, применяя этот метод. Применение этого метода, кроме того, означает невозможность работы со структурами данных ядра и невозможность использования фреймворков вроде BCC, компилирующих программы eBPF во время выполнения кода.
  4. Если вас не интересуют короткоживущие процессы и предыдущие рекомендации вам не подходят — воспользуйтесь возможностями netlink совместно с /proc.

источник