- PVSM.RU - https://www.pvsm.ru -
[1]В одном проекте, связанном с безопасностью Linux-систем, нам потребовалось перехватывать вызовы важных функций внутри ядра (вроде открытия файлов и запуска процессов) для обеспечения возможности мониторинга активности в системе и превентивного блокирования деятельности подозрительных процессов.
В процессе разработки нам удалось изобрести довольно неплохой подход, позволяющий удобно перехватить любую функцию в ядре по имени и выполнить свой код вокруг её вызовов. Перехватчик можно устанавливать из загружаемого GPL-модуля, без пересборки ядра. Подход поддерживает ядра версий 3.19+ для архитектуры x86_64.
(Изображение пингвина чуть выше: © En3l с DeviantArt [1].)
Наиболее правильным было бы использование Linux Security API — специального интерфейса, созданного именно для этих целей. В критических местах ядерного кода расположены вызовы security-функций, которые в свою очередь вызывают коллбеки, установленные security-модулем. Security-модуль может изучать контекст операции и принимать решение о её разрешении или запрете.
К сожалению, у Linux Security API есть пара важных ограничений:
Если по поводу множественности модулей позиция разработчиков ядра неоднозначная, то запрет на динамическую загрузку принципиальный: security-модуль должен быть частью ядра, чтобы обеспечивать безопасность постоянно, с момента загрузки.
Таким образом, для использования Security API необходимо поставлять собственную сборку ядра, а также интегрировать дополнительный модуль с SELinux или AppArmor, которые используются популярными дистрибутивами. Заказчик на подобные обязательства подписываться не хотел, поэтому этот путь оказался закрыт.
По этим причинам Security API нам не подошёл, иначе он был бы идеальным вариантом.
Мониторинг требовался в основном для действий, выполняемых пользовательскими приложениями, так что в принципе мог бы быть реализован на уровне системных вызовов. Как известно, Linux хранит все обработчики системных вызовов в таблице sys_call_table
. Подмена значений в этой таблице приводит к смене поведения всей системы. Таким образом, сохранив старое значения обработчика и подставив в таблицу собственный обработчик, мы можем перехватить любой системный вызов.
У этого подхода есть определённые преимущества:
Однако, он также страдает от некоторых недостатков:
Это всё интересные вещи, но они требуют драгоценного времени разработчиков сначала на реализацию, а затем на поддержку и понимание.
Изначально мы выбрали и успешно реализовали именно этот подход, преследуя выгоды от поддержки наибольшего количества систем. Однако, в то время мы ещё не знали об особенностях x86_64 и ограничениях на перехватываемые вызовы. Позже для нас оказалась критичной поддержка системных вызовов, связанных с запуском новых процессов — clone() и execve(),— которые как раз являются особенными. Именно это и привело нас к поиску новых вариантов.
Одним из вариантов, которые рассматривались, было использование kprobes: специализированного API, в первую очередь предназначенного для отладки и трассирования ядра. Этот интерфейс позволяет устанавливать пред- и постобработчики для любой инструкции в ядре, а также обработчики на вход и возврат из функции. Обработчики получают доступ к регистрам и могут их изменять. Таким образом, мы бы могли получить как мониторинг, так и возможность влиять на дальнейший ход работы.
Преимущества, которые даёт использование kprobes для перехвата:
Недостатки kprobes:
В процессе исследования темы наш взгляд упал на фреймворк ftrace, способный заменить jprobes. Как оказалось, для наших нужд перехвата вызовов функций он подходит лучше. Однако, если вам необходимо трассирование конкретных инструкций внутри функций, то kprobes не стоит списывать со счетов.
Для полноты картины стоит также описать классический способ перехвата функций, заключающийся в замене инструкций в начале функции на безусловный переход, ведущий в наш обработчик. Оригинальные инструкции переносятся в другое место и исполняются перед переходом обратно в перехваченную функцию. С помощью двух переходов мы вшиваем (splice in) свой дополнительный код в функцию, поэтому такой подход называется сплайсингом.
Именно таким образом и реализуется jump-оптимизация для kprobes. Используя сплайсинг можно добиться тех же результатов, но без дополнительных расходов на kprobes и с полным контролем ситуации.
Преимущества сплайсинга очевидны:
Однако, главный недостаток этого подхода серьёзно омрачает картину:
Да, можно подсматривать в kprobes и использовать внутриядерный фреймворк livepatch, но итоговое решение всё равно остаётся довольно сложным. Страшно представить, какое количество спящих проблем будет в каждой новой его реализации.
В общем, если вы способны призвать этого демона, подчиняющего только посвящённым, и готовы терпеть его в своём коде, то сплайсинг — это вполне рабочий подход для перехвата вызовов функций. Я негативно относился к написанию велосипедов, поэтому этот вариант оставался для нас резервным на случай, если совсем не будет никакого прогресса с готовыми решениями попроще.
Ftrace — это фреймворк для трассирования ядра на уровне функций. Он разрабатывается с 2008 года и обладает просто фантастическим интерфейсом для пользовательских программ. Ftrace позволяет отслеживать частоту и длительность вызовов функций, отображать графы вызовов, фильтровать интересующие функции по шаблонам, и так далее. О возможностях ftrace можно начать читать отсюда [2], и дальше по приведённым ссылкам и официальной документации.
Реализуется ftrace на основе ключей компилятора -pg
и -mfentry
, которые вставляют в начало каждой функции вызов специальной трассировочной функции mcount() или __fentry__(). Обычно, в пользовательских программах эта возможность компилятора используется профилировщиками, чтобы отслеживать вызовы всех функций. Ядро же использует эти функции для реализации фреймворка ftrace.
Вызывать ftrace из каждой функции — это, разумеется, не дёшево, поэтому для популярных архитектур доступна оптимизация: динамический ftrace. Суть в том, что ядро знает расположение всех вызовов mcount() или __fentry__() и на ранних этапах загрузки заменяет их машинный код на nop — специальную ничего не делающую инструкцию. При включении трассирования в нужные функции вызовы ftrace добавляются обратно. Таким образом, если ftrace не используется, то его влияние на систему минимально.
Каждую перехватываемую функцию можно описать следующей структурой:
/**
* struct ftrace_hook - описывает перехватываемую функцию
*
* @name: имя перехватываемой функции
*
* @function: адрес функции-обёртки, которая будет вызываться вместо
* перехваченной функции
*
* @original: указатель на место, куда следует записать адрес
* перехватываемой функции, заполняется при установке
*
* @address: адрес перехватываемой функции, выясняется при установке
*
* @ops: служебная информация ftrace, инициализируется нулями,
* при установке перехвата будет доинициализирована
*/
struct ftrace_hook {
const char *name;
void *function;
void *original;
unsigned long address;
struct ftrace_ops ops;
};
Пользователю необходимо заполнить только первые три поля: name, function, original. Остальные поля считаются деталью реализации. Описание всех перехватываемых функций можно собрать в массив и использовать макросы, чтобы повысить компактность кода:
#define HOOK(_name, _function, _original)
{
.name = (_name),
.function = (_function),
.original = (_original),
}
static struct ftrace_hook hooked_functions[] = {
HOOK("sys_clone", fh_sys_clone, &real_sys_clone),
HOOK("sys_execve", fh_sys_execve, &real_sys_execve),
};
Обёртки над перехватываемыми функциями выглядят следующим образом:
/*
* Это указатель на оригинальный обработчик системного вызова execve().
* Его можно вызывать из обёртки. Очень важно в точности соблюдать
* сигнатуру функции: порядок и типы аргументов и возвращаемого значения,
* а также спецификаторы ABI (внимание на "asmlinkage").
*/
static asmlinkage long (*real_sys_execve)(const char __user *filename,
const char __user *const __user *argv,
const char __user *const __user *envp);
/*
* Эта функция будет вызываться вместо перехваченной. Её аргументы — это
* аргументы оригинальной функции. Её возвращаемое значение будет передано
* вызывающей функции. Она может выполнять произвольный код до, после
* или вместо оригинальной функции.
*/
static asmlinkage long fh_sys_execve(const char __user *filename,
const char __user *const __user *argv,
const char __user *const __user *envp)
{
long ret;
pr_debug("execve() called: filename=%p argv=%p envp=%pn",
filename, argv, envp);
ret = real_sys_execve(filename, argv, envp);
pr_debug("execve() returns: %ldn", ret);
return ret;
}
Как видим, перехватываемые функции с минимумом лишнего кода. Единственный момент, требующий тщательного внимания — это сигнатуры функций. Они должны совпадать один к одному. Без этого, очевидно, аргументы будут переданы неправильно и всё пойдёт под откос. Для перехвата системных вызовов это важно в меньшей степени, так как их обработчики очень стабильные и для эффективности аргументы принимают в том же порядке, что и сами системные вызовы. Однако, если вы планируете перехватывать другие функции, то следует помнить о том, что внутри ядра стабильных интерфейсов нет [3].
Для начала нам потребуется найти и сохранить адрес функции, которую мы будем перехватывать. Ftrace позволяет трассировать функции по имени, но нам всё равно надо знать адрес оригинальной функции, чтобы вызывать её.
Добыть адрес можно с помощью kallsyms — списка всех символов в ядре. В этот список входят все символы, не только экспортируемые для модулей. Получение адреса перехватываемой функции выглядит примерно так:
static int resolve_hook_address(struct ftrace_hook *hook)
{
hook->address = kallsyms_lookup_name(hook->name);
if (!hook->address) {
pr_debug("unresolved symbol: %sn", hook->name);
return -ENOENT;
}
*((unsigned long*) hook->original) = hook->address;
return 0;
}
Дальше необходимо инициализировать структуру ftrace_ops
. В ней обязательным
полем является лишь func, указывающая на коллбек, но нам также необходимо
установить некоторые важные флаги:
int fh_install_hook(struct ftrace_hook *hook)
{
int err;
err = resolve_hook_address(hook);
if (err)
return err;
hook->ops.func = fh_ftrace_thunk;
hook->ops.flags = FTRACE_OPS_FL_SAVE_REGS
| FTRACE_OPS_FL_IPMODIFY;
/* ... */
}
fh_ftrace_thunk() — это наш коллбек, который ftrace будет вызывать при трассировании функции. О нём позже. Флаги, которые мы устанавливаем, будут необходимы для выполнения перехвата. Они предписывают ftrace сохранить и восстановить регистры процессора, содержимое которых мы сможем изменить в коллбеке.
Теперь мы готовы к включению перехвата. Для этого необходимо сначала включить ftrace для интересующей нас функции с помощью ftrace_set_filter_ip(), а затем разрешить ftrace вызывать наш коллбек с помощью register_ftrace_function():
int fh_install_hook(struct ftrace_hook *hook)
{
/* ... */
err = ftrace_set_filter_ip(&hook->ops, hook->address, 0, 0);
if (err) {
pr_debug("ftrace_set_filter_ip() failed: %dn", err);
return err;
}
err = register_ftrace_function(&hook->ops);
if (err) {
pr_debug("register_ftrace_function() failed: %dn", err);
/* Не забываем выключить ftrace в случае ошибки. */
ftrace_set_filter_ip(&hook->ops, hook->address, 1, 0);
return err;
}
return 0;
}
Выключается перехват аналогично, только в обратном порядке:
void fh_remove_hook(struct ftrace_hook *hook)
{
int err;
err = unregister_ftrace_function(&hook->ops);
if (err) {
pr_debug("unregister_ftrace_function() failed: %dn", err);
}
err = ftrace_set_filter_ip(&hook->ops, hook->address, 1, 0);
if (err) {
pr_debug("ftrace_set_filter_ip() failed: %dn", err);
}
}
После завершения вызова unregister_ftrace_function() гарантируется отсутствие активаций установленного коллбека в системе (а вместе с ним — и наших обёрток). Поэтому мы можем, например, спокойно выгрузить модуль-перехватчик, не опасаясь, что где-то в системе ещё выполняются наши функции (ведь если они пропадут, то процессор расстроится).
Как же выполняется собственно перехват? Очень просто. Ftrace позволяет изменять состояние регистров после выхода из коллбека. Изменяя регистр %rip — указатель на следующую исполняемую инструкцию,— мы изменяем инструкции, которые исполняет процессор — то есть можем заставить его выполнить безусловный переход из текущей функции в нашу. Таким образом мы перехватываем управление на себя.
Коллбек для ftrace выглядит следующим образом:
static void notrace fh_ftrace_thunk(unsigned long ip, unsigned long parent_ip,
struct ftrace_ops *ops, struct pt_regs *regs)
{
struct ftrace_hook *hook = container_of(ops, struct ftrace_hook, ops);
regs->ip = (unsigned long) hook->function;
}
С помощью макроса container_of() мы получаем адрес нашей struct ftrace_hook
по адресу внедрённой в неё struct ftrace_ops
, после чего заменяем значение регистра %rip в структуре struct pt_regs
на адрес нашего обработчика. Всё. Для архитектур, отличных от x86_64, этот регистр может называться по-другому (вроде IP или PC), но идея в принципе применима и для них.
Обратите внимание на спецификатор notrace, добавленный для коллбека. Им можно помечать функции, запрещённые для трассировки с помощью ftrace. Например, так помечены функции самого ftrace, задействованные в процессе трассировки. Это помогает предотвратить зависание системы в бесконечном цикле при трассировании всех функций в ядре (ftrace так умеет).
Коллбек ftrace обычно вызывает с отключенным вытеснением (как и kprobes). Возможны исключения, но на них не стоит рассчитывать. В нашем случае, правда, это ограничение не важно, так мы всего лишь заменяем восемь байтов в структуре.
Функция-обёртка, которая вызывается позже, будет выполняться в том же контексте, что и оригинальная функция. Поэтому там можно делать то же, что позволено делать в перехватываемой функции. Например, если вы перехватываете обработчик прерывания, то спать в обёртке всё ещё нельзя.
В коде выше есть подвох: когда наша обёртка вызовет оригинальную функцию, та опять попадёт в ftrace, который опять вызовет наш коллбек, который опять передаст управление обёртке. Эту бесконечную рекурсию необходимо как-то оборвать.
Наиболее элегантный способ, который пришёл нам в голову — это использовать parent_ip
— один из аргументов ftrace-коллбека, который содержит адрес возврата в функцию, которая вызвала трассируемую функцию. Обычно этот аргумент используют для построения графа вызовов функций. Мы же можем воспользоваться им для того, чтобы отличить первый вызов перехваченной функции от повторного.
Действительно, при повторном вызове parent_ip
должен указывать внутрь нашей обёртки, тогда как при первом — куда-то в другое место ядра. Передавать управление следует только при первом вызове функции, все другие должны дать выполниться оригинальной функции.
Проверку на вхождение можно очень эффективно выполнить, сравнивая адрес с границами текущего модуля (который содержит все наши функции). Это отлично работает в случае, если в модуле лишь обёртка вызывает перехваченную функцию. В противном случае необходимо быть более избирательным.
Итого, правильный ftrace-коллбек выглядит следующим образом:
static void notrace fh_ftrace_thunk(unsigned long ip, unsigned long parent_ip,
struct ftrace_ops *ops, struct pt_regs *regs)
{
struct ftrace_hook *hook = container_of(ops, struct ftrace_hook, ops);
/* Пропускаем вызовы функции из текущего модуля. */
if (!within_module(parent_ip, THIS_MODULE))
regs->ip = (unsigned long) hook->function;
}
Отличительные особенности/преимущества данного подхода:
Рассмотрим пример: вы набрали в терминале команду ls, чтобы увидеть список файлов в текущей директории. Командный интерпретатор (скажем, Bash) для запуска нового процесса использует традиционную пару функций fork() + execve() из стандартной библиотеки языка Си. Внутри эти функции реализуются через системные вызовы clone() и execve() соответственно. Допустим, мы перехватываем системный вызов execve(), чтобы контролировать запуск новых процессов.
В графическом виде перехват функции-обработчика выглядит так:
Здесь мы видим, как пользовательский процесс (голубой) выполняет системный вызов в ядро (красное), где фреймворк ftrace (фиолетовый) вызывает функции из нашего модуля (зелёного).
sys_call_table
и вызывает оттуда конкретный обработчик по номеру системного вызова — в нашем случае это будет функция sys_execve().
parent_ip
, указывающее внутрь do_syscall_64() — так как именно эта функция вызвала обработчик sys_execve() — и принимает решение выполнит перехват, обновляя значение регистра %rip в структуре pt_regs
.
pt_regs
перед вызовом обработчиков. При завершении обработки ftrace восстанавливает регистры из этой структуры. Наш обработчик изменяет регистр %rip — указатель на следующую исполняемую инструкцию — что в итоге приводит к передаче управления по новому адресу.
В итоге мы получаем очень удобный способ перехвата любых функций в ядре, обладающий следующими преимуществами:
Какие же недостатки у этого решения?
Все эти возможности не являются критичными для функционирования системы и могут быть отключены в конфигурации ядра. Правда, обычно ядра, используемые популярными дистрибутивами, все эти опции в себе всё равно содержат, так как они не влияют на производительность и полезны при отладке. Однако, если вам необходимо поддерживать какие-то особенные ядра, то следует иметь в виду эти требования.
parent_ip
приводит к повторному вызову ftrace для перехваченных функций. Это добавляет немного накладных расходов и может сбивать показания других трассировок, которые будут видеть в два раза больше вызовов. Этого недостатка можно избежать, применив немного чёрной магии: вызов ftrace расположен в начале функции, так что если адрес оригинальной функции сдвинуть вперёд на 5 байтов (длина инструкции call), то через ftrace можно перескочить.
Рассмотрим некоторые недостатки подробнее.
Для начала, ядра должно поддерживать ftrace и kallsyms. Для этого должны быть включены следующие опции:
Затем, ftrace должна поддерживать динамическую модификацию регистров. За эту возможность отвечает опция
Далее, используемое ядро должно быть основано на версии 3.19 или выше, чтобы иметь доступ к флагу FTRACE_OPS_FL_IPMODIFY. Более ранние версии ядра тоже умеют заменять регистр %rip, но начиная с 3.19 это следует делать только после установки данного флага. Наличие флага для старых ядер приведёт к ошибке компиляции, а его отсутствие для новых — к неработающему перехвату.
Наконец, для выполнения перехвата критическим является расположение вызова ftrace внутри функции: вызов должен располагаться в самом начале, до пролога функции (где выделяется место под локальные переменные и формируется стековый фрейм). Эта особенность архитектуры учитывается опцией
Архитектура x86_64 поддерживает эту опцию, а вот i386 — нет. Из-за ограничений архитектуры i386 компилятор не может вставить вызов ftrace до пролога функции, поэтому к моменту вызова ftrace стек функции уже оказывается модифицированным. В таком случае для перехвата недостаточно лишь изменить значение регистра %eip — нужно ещё обратить все действия, выполненные в прологе, которые отличаются от функции к функции.
По этой причине перехват с помощью ftrace не поддерживает 32-битную архитектуру x86. В принципе, его можно было бы реализовать с помощью определённой чёрной магии (генерируя и выполняя «антипролог»), но тогда пострадает техническая простота решения, являющаяся одним из преимуществ использования ftrace.
Во время тестирования мы столкнулись с одной интересной особенностью: на некоторых дистрибутивах перехват функций приводил к зависанию системы намертво. Естественно, это происходило только на системах, отличных от используемых разработчиками. Проблема также не воспроизводилась на исходном прототипе перехвата, с любыми дистрибутивами и версиями ядер.
Отладка показывала, что зависание происходит внутри перехваченной функции. По какой-то мистической причине при вызове оригинальной функции внутри ftrace-коллбека адрес parent_ip
продолжал указывать в код ядра вместо кода функции обёртки. Из-за этого возникал бесконечный цикл, так как ftrace раз за разом вызывал нашу обёртку, не выполняя каких-либо полезных действий.
К счастью, у нас был в распоряжении как рабочий, так и поломанный код, поэтому нахождение различий было лишь вопросом времени. После проведённой унификации кода и выбрасывания всего ненужного, различия между версиями удалось локализовать до функции-обёртки.
Вот этот вариант работал:
static asmlinkage long fh_sys_execve(const char __user *filename,
const char __user *const __user *argv,
const char __user *const __user *envp)
{
long ret;
pr_debug("execve() called: filename=%p argv=%p envp=%pn",
filename, argv, envp);
ret = real_sys_execve(filename, argv, envp);
pr_debug("execve() returns: %ldn", ret);
return ret;
}
а вот этот — вешал систему:
static asmlinkage long fh_sys_execve(const char __user *filename,
const char __user *const __user *argv,
const char __user *const __user *envp)
{
long ret;
pr_devel("execve() called: filename=%p argv=%p envp=%pn",
filename, argv, envp);
ret = real_sys_execve(filename, argv, envp);
pr_devel("execve() returns: %ldn", ret);
return ret;
}
Как так выходит, что уровень логгирования влияет на поведение? Внимательное изучение машинного кода двух функций быстро прояснило ситуацию и вызвало то самое чувство, когда виноват именно компилятор. Обычно он находится в списке подозреваемых где-то рядом с космическими лучами, но не в этот раз.
Дело, как оказалось, в том, что вызовы pr_devel() раскрываются в пустоту. Этот вариант printk-макроса используется для логгирования во время разработки. Такие записи в лог не интересны при эксплуатации, поэтому автоматически вырезаются из кода, если не объявить макрос DEBUG. После этого функция для компилятора превращается в такую:
static asmlinkage long fh_sys_execve(const char __user *filename,
const char __user *const __user *argv,
const char __user *const __user *envp)
{
return real_sys_execve(filename, argv, envp);
}
И тут на сцену выходят оптимизации. В данном случае сработала так называемая оптимизация хвостовых вызовов (tail call optimization). Она позволяет компилятору заменить честный вызов функции на прямой переход к её телу, если одна функция вызывает другую и сразу же возвращает её значение. В машинном коде честный вызов выглядит так:
0000000000000000 <fh_sys_execve>:
0: e8 00 00 00 00 callq 5 <fh_sys_execve+0x5>
5: ff 15 00 00 00 00 callq *0x0(%rip)
b: f3 c3 repz retq
а нерабочий — вот так:
0000000000000000 <fh_sys_execve>:
0: e8 00 00 00 00 callq 5 <fh_sys_execve+0x5>
5: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax
c: ff e0 jmpq *%rax
Первая инструкция CALL — это тот самый вызов __fentry__(), вставляемый компилятором в начало всех функций. А вот дальше в нормальном коде видно вызов real_sys_execve (по указателю в памяти) через инструкцию CALL и возврат из fh_sys_execve() с помощью инструкции RET. Поломанный же код переходит к функции real_sys_execve() напрямую с помощью JMP.
Оптимизация хвостовых вызовов позволяет сэкономить немного времени на формировании «бессмысленного» стекового фрейма, в который входит и адрес возврата, сохраняемый в стеке инструкцией CALL. Однако, для нас корректность адреса возврата играет критичную роль — мы используем parent_ip
для принятия решения о перехвате. После оптимизации функция fh_sys_execve() больше не сохраняет новый адрес возврата на стеке, там остаётся старый — указывающий в ядро. Поэтому parent_ip
продолжает указывать внутрь ядра, что и приводит в конечном итоге к образованию бесконечного цикла.
Это также объясняет, почему проблема воспроизводилась лишь на некоторых дистрибутивах. При компиляции модулей разные дистрибутивы используют разные наборы флагов компиляции. В проблемных дистрибутивах оптимизация хвостовых вызовов была включена по умолчанию.
Решением проблемы для нас стало отключение оптимизации хвостовых вызовов для всего файла с функциями-обёртками:
#pragma GCC optimize("-fno-optimize-sibling-calls")
Что ещё можно сказать… Разработка низкоуровневого кода для ядра Linux — это весело. Я надеюсь, эта публикация сэкономит кому-то немного времени при муках выбора, что же использовать для написания своего лучшего в мире антивируса.
Если вам охота поэкспериментировать с перехватом самостоятельно, то полный код модуля ядра можно найти на Github [6].
Автор: ilammy
Источник [7]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/linux/281995
Ссылки в тексте:
[1] Image: https://en3l.deviantart.com/art/Ninja-penguin-207123041
[2] отсюда: https://habr.com/company/selectel/blog/280322/
[3] внутри ядра стабильных интерфейсов нет: https://dri.freedesktop.org/docs/drm/process/stable-api-nonsense.html
[4] реализованные на ассемблере: https://elixir.bootlin.com/linux/v4.16/source/arch/x86/entry/entry_64.S#L206
[5] написанной на Си: https://elixir.bootlin.com/linux/v4.16/source/arch/x86/entry/common.c#L269
[6] на Github: https://github.com/ilammy/ftrace-hook
[7] Источник: https://habr.com/post/413241/?utm_campaign=413241
Нажмите здесь для печати.