milabs
Перехват функций ядра является базовым методом, позволяющим переопределять (дополнять) различные его механизмы. Исходя из того, что за исключением небольших архитектурно-зависимых частей, ядро Linux почти полностью написано на языке C, можно утверждать, что для осуществления встраивания в большинство из компонентов ядра, достаточно иметь возможность перехвата соответствующих функций ЯВУ, реализующих ту или иную логику.
Данная статья является практическим обобщением представленных ранее статей:
- Управляемый PageFault в ядре Linux
- Кошерный способ модификации защищённых от записи областей ядра Linux
Далее будет рассмотрено каким образом использование данных материалов может быть применимо в обеспечении возможности перехвата функций ядра Linux.
Кратенько о перехвате
Целью перехвата любой функции является получение управления в момент её вызова. Дальнейшие действия зависят от конкретных задач. В одних случаях, необходимо заменить системную реализацию алгоритма своей, в других - дополнить. При этом бывает важно оставить возможность использования перехватываемой функции в своих целях.
Традиционным при осуществлении перехвата стал подход при котором используется концепция "обёрток", позволяющая реализовать пре- и пост-обработку с сохранением возможности доступа к исходному функционалу представляемому перехватываемой функцией.
Как известно, основой большинства методов перехвата функций является патчинг - модификация кода ядра таким образом, чтобы обеспечить возможность передачи управления на функцию-перехватчик при вызове целевой функции. При этом, в силу развитой системы команд архитектуры x86, возможно существование множества вариантов изменения потока выполнения (да, JMP <REL32>
- только один из них: подробнее).
Дабы внести разнообразие в тему перехвата функций ядра, в данной статье будет описан механизм перехвата, основанный на обработке исключений, рассматриваемой в одной из предыдущих статей. Кроме этого, особое внимание будет уделено практической составляющей, а именно удобству использования предлагаемого подхода в нашей "повседневной жизни".
Методика осуществления перехвата
Итак, методика осуществления перехвата с использованием исключений будет состоять в том, чтобы модифицировать пролог целевой функции таким образом, чтобы его выполнение процессором приводило к исключению, обработка которого являлась бы контролируемой.
Другими словами, для каждой целевой функции осуществим модификацию пролога путём записи в её начало команды UD2
с предварительной настройкой обработчиков исключений для всех таких адресов. Всё это позволит ловить возникающие исключения (#UD
) и обрабатывать их соответствующим образом. Именно так работает нативный механизм ядра Linux kprobes, за исключением того, что генерирующей исключение инструкцией является команда INT3
.
Например, если до перехвата функция inode_permission имеет вид:
ffffffff8118dd80 <inode_permission>: ffffffff8118dd80: 55 push %rbp ffffffff8118dd81: 48 89 e5 mov %rsp,%rbp ffffffff8118dd84: e8 f7 b7 4f 00 callq ffffffff81689580 <mcount> ffffffff8118dd89: 40 f6 c6 02 test $0x2,%sil
То после модификации в соответствии с предлагаемой методикой, её пролог будет выглядеть следующим образом
ffffffff8118dd80 <inode_permission>: ffffffff8118dd80: 0f b0 ud2 => ИСКЛЮЧЕНИЕ #UD ffffffff8118dd82: 89 e5 ??? ffffffff8118dd84: e8 f7 b7 4f 00 callq ffffffff81689580 <mcount> ffffffff8118dd89: 40 f6 c6 02 test $0x2,%sil
Именно записанная поверх оригинальных инструкций команда UD2
приведёт к генерации требуемого исключения, а рассмотренный ранее механизм обработки позволит зарегистрировать адрес ffffffff8118dd80
как адрес, используемый при поиске соответствующего обработчика, задачей которого будет реализация функционала перехватываемой функции.
Функциональные возможности модуля перехвата
Для того, чтобы реализация имела более высокую практическую ценность, будем пытаться оформить рассмотренную методику осуществления перехвата в удобный для работы инструмент. Для этого, поставим целью создание программного механизма, позволяющего с минимальными телодвижениями перехватывать функции ядра на основании только лишь их имён и прототипов. Имя будет служить основой для поиска адреса функции (символа), а прототип - характеризовать передаваемые функции аргументы. Далее, дабы было понятно к чему всё это должно привести, я приведу конечный результат, а затем дам пояснение, что к чему.
Итак, конечной целью является возможность реализовывать перехваты функций ядра следующим образом:
#include <linux/fs.h> // inode_permission() prototype lives here DECLARE_KHOOK(inode_permission); int khook_inode_permission(struct inode * inode, int mode) { int result; KHOOK_USAGE_INC(inode_permission); ... result = KHOOK_ORIGIN(inode_permission, inode, mode); ... KHOOK_USAGE_DEC(inode_permission); return result; }
Как нетрудно видеть, в приведённом примере осуществляется перехват функции inode_permission. При этом, в представленном фрагменте присутствуют макросы:
- регистрации перехвата DECLARE_KHOOK
- вызова оригинальной функции KHOOK_ORIGIN
- инкремента и декремента счётчиков вхождений KHOOK_USAGE_INC и KHOOK_USAGE_DEC
Первым из аргументов всех макросов является имя перехватываемой функции, служащее идентификатором формируемых и используемых макросами элементов.
Макрос DECLARE_KHOOK(...)
является основным и создаёт необходимое для перехвата окружение, используя имя функции и её прототип, а также резервируя специальное имя для реализации функции-перехватчика, формируемое с использованием базового прототипа, имени и префикса khook_...
Макрос KHOOK_ORIGIN(...)
служит для вызова оригинальной (перехватываемой) функции, необходимость чего обусловлена фактом внесения изменений в оригинальный пролог, что в свою очередь ведёт к невозможности прямого вызова перехватываемой функции.
Макросы KHOOK_USAGE_INC(...)
и KHOOK_USAGE_DEC(...)
являются вспомогательными и служат для того, чтобы иметь возможность учёта количества потоков, висящих внутри функции, что необходимо учитывать при осуществлении снятия установленных перехватов.
Особенности реализации модуля перехвата
Каждый, декларируемый с использованием макроса DECLARE_KHOOK(...)
описывается с использованием следующей структуры:
typedef struct { /* tagret's name */ char * name; /* target's insn length */ int length; /* target's handler address */ void * handler; /* target's address and rw-mapping */ void * target; void * target_map; /* origin's address and rw-mapping */ void * origin; void * origin_map; atomic_t usage; } khookstr_t;
В приведённой структуре: name
- имя перехватываемой функции, length
- длина затираемой последовательности инструкций пролога, handler
- адрес функции-перехватчика, target
- адрес самой целевой функции, target_map
- адрес доступной для записи проекции целевой функции, origin
- адрес функции-переходника, используемой для доступа к исходному функционалу, origin_map
- адрес доступной для записи проекции соответствующего переходника, usage
- счётчик "залипаний", учитывающий число спящих в перехвате потоков.
Ниже приведён сам макрос DECLARE_KHOOK(...)
, по которому можно видеть каким образом формируется вся необходимая для перехвата информация:
#define __DECLARE_TARGET_ALIAS(t) \ void __attribute__((alias("khook_"#t))) khook_alias_##t(void) #define __DECLARE_TARGET_ORIGIN(t) \ void notrace khook_origin_##t(void) { \ asm volatile ( \ ".rept 0x20\n" \ ".byte 0x90\n" \ ".endr\n" \ ); \ } #define __DECLARE_TARGET_STRUCT(t) \ khookstr_t __attribute__((unused,section(".khook"),aligned(1))) __khook_##t #define DECLARE_KHOOK(t) \ __DECLARE_TARGET_ALIAS(t); \ __DECLARE_TARGET_ORIGIN(t); \ __DECLARE_TARGET_STRUCT(t) = { \ .name = #t, \ .handler = khook_alias_##t, \ .origin = khook_origin_##t, \ .usage = ATOMIC_INIT(0), \ }
Вспомогательные макросы __DECLARE_TARGET_ALIAS(...)
, __DECLARE_TARGET_ORIGIN(...)
декларируют перехватчик и переходник (32 nop'а). Саму структуру объявляет макрос __DECLARE_TARGET_STRUCT(...)
, посредством атрибута section
определяя её в специальную секцию (".khook"). Заполнением же данной структуры занимается рассматриваемый макрос DECLARE_KHOOK(...)
.
Таким образом, после такой декларации для целевой функции создаётся структура описатель, которая попадает в специальную секцию. Это позволяет далее использовать конструкции типа:
extern khookstr_t __khook_start[], __khook_finish[]; #define khook_for_each(item) \ for (item = __khook_start; item < __khook_finish; item++)
Далее, при загрузке модуля описанным образом происходит перечисление всех зарегистрированных перехватов. Для каждого из них осуществляется поиск адреса соответствующего символа, а также настройка вспомогательных элементов, включая создание и заполнение таблицы исключений. Ниже представлен код основной функции:
static int init_hooks(void) { khookstr_t * s; int num_exentries = 0; struct exception_table_entry * extable; extable = (void *)pfnModuleAlloc(sizeof(*extable) * (__khook_finish - __khook_start)); if (extable == NULL) { debug("Memory allocation failed\n"); return -ENOMEM; } khook_for_each(s) { s->target = get_symbol_address(s->name); if (s->target) { s->target_map = map_writable(s->target, 32); s->origin_map = map_writable(s->origin, 32); if (s->target_map && s->origin_map) { if (init_origin_stub(s) == 0) { struct exception_table_entry * entry = &extable[num_exentries++]; /* OK, the stub is initialized */ atomic_inc(&s->usage); extable_make_insn(entry, (unsigned long)s->target); extable_make_fixup(entry, (unsigned long)s->handler); continue; } } } debug("Failed to initalize \"%s\" hook", s->name); } pfnSortExtable(extable, extable + num_exentries); THIS_MODULE->extable = extable; THIS_MODULE->num_exentries = num_exentries; /* apply patches */ stop_machine(do_init_hooks, NULL, NULL); return 0; }
Изменения в код ядра вносятся, как это описано в статье, посвящённой модификации защищённых от записи областей ядра с использованием проекций и механизма stop_machine
:
static int do_init_hooks(void * arg) { khookstr_t * s; khook_for_each(s) { if (atomic_read(&s->usage) == 1) x86_put_ud2(s->target_map); } return 0; }
К моменту выполнения do_init_hooks
, всё уже настроено и готово к работе. Поэтому, код этой функции простой - запись инструкции UD2
в каждый целевой пролог и ничего более.
Что касается возможности вызова оригинальной функции после того, как её пролог был "испорчен", то для этого необходимо сохранять затираемые инструкции в специальном месте - функции-переходнике (khook_origin_...
). При этом, завершающей инструкцией должна быть инструкция безусловного перехода (JMP
) на первую "рабочую" команду оригинальной функции. Как было отмечено, переходники создаются в процессе декларации перехватов, а настраиваются в процессе загрузки модуля, за что отвечает следующая функция:
static int init_origin_stub(khookstr_t * s) { ud_t ud; ud_initialize(&ud, BITS_PER_LONG, \ UD_VENDOR_ANY, (void *)s->target, 32); while (ud_disassemble(&ud) && ud.mnemonic != UD_Iret) { if (ud.mnemonic == UD_Iud2 // ud.mnemonic == UD_Iint3) { debug("It seems that \"%s\" is not a hooking virgin\n", s->name); return -EINVAL; } #define UD2_INSN_LEN 2 s->length += ud_insn_len(&ud); if (s->length >= UD2_INSN_LEN) { memcpy(s->origin_map, s->target, s->length); x86_put_jmp(s->origin_map + s->length, s->origin + s->length, s->target + s->length); break; } } return 0; }
Обращение к исходному функционалу перехватываемой функции осуществляется с помощью макроса, имеющего вид:
#define KHOOK_ORIGIN(t, ...) \ ((typeof(t) *)__khook_##t.origin)(__VA_ARGS__)
Заключение
В заключение стоит отметить, что реализованная методика осуществления перехватов функций ядра с использованием исключений подтверждает возможность практического применения возможности пользовательской обработки исключений в ядре Linux. Представленные в данной статье материалы позволяют понять каким образом осуществляется перехват функций, а приводимый на github код проекта может служить учебным примером тому, кто интересуется данной тематикой.
Ссылки по теме