Перехват функций ядра является базовым методом, позволяющим переопределять/дополнять различные его механизмы. Исходя из того, что ядро Linux почти полностью написано на языке C, за исключением небольших архитектурно-зависимых частей, можно утверждать, что для осуществления встраивания в большинство из компонентов ядра достаточно иметь возможность перехвата соответствующих функций.
Данная статья является продолжением анонсированного ранее цикла, посвящённого частным вопросам реализации наложенных средств защиты и, в частности, встраиванию в программные системы.
Целью перехвата любой функции является получение управления в момент её вызова. Дальнейшие действия зависят от конкретных задач. В одних случаях, необходимо заменить системную реализацию алгоритма своей, в других — дополнить. При этом бывает важно оставить возможность использования перехватываемой функции в своих целях.
Традиционным при осуществлении перехвата стал подход при котором используется концепция «обёрток», позволяющая реализовать пре- и пост-обработку с сохранением возможности доступа к исходной функциональности представляемой перехватываемой функцией.
Основой большинства методов перехвата функций является патчинг — модификация кода ядра таким образом, чтобы обеспечить возможность передачи управления на функцию-перехватчик при вызове целевой функции. При этом в силу развитой системы команд архитектуры x86, возможно существование множества вариантов изменения потока выполнения (да, JMP — только один из них: подробнее).
Суть описываемого способа осуществления перехвата будет состоять в том, чтобы модифицировать пролог (начало) целевой функции таким образом, чтобы его выполнение процессором приводило передаче управления на функцию-обработчик.
Другими словами, для каждой целевой функции осуществим модификацию пролога путём записи в её начало команды JMP . Это позволит переключить поток выполнения с целевой функции на соответствующий обработчик.
Например, если до перехвата функция inode_permission имеет вид:
То после перехвата, пролог этой функции будет выглядеть следующим образом:
Именно записанная поверх оригинальных инструкций пяти-байтовая команда JMP с кодом E9.XX.XX.XX.XX приводит к передачи управления. В этом и состоит основная суть описываемого способа осуществления перехвата. Далее будут рассмотрены некоторые особенности его реализации в ядре Linux.
Как было отмечено, суть патчинга заключается в модификации кода ядра. Основной проблемой, возникающей при этом является то, что запись в страницы памяти, содержащие код невозможна т.к. в архитектуре x86 существует специальный защитный механизм, в соответствии с которым попытка записи в защищённые от записи области памяти может приводить к генерации исключения. Данный механизм носит название «страничной защиты» и является базовым для реализации многих функций ядра, таких, как например COW. Поведение процессора в этой ситуации определяется битом WP регистра CR0, а права доступа к странице описываются в соответствующей ей структуре-описателе PTE. При установленном бите WP регистра CR0 попытка записи в защищённые от записи страницы (cброшен бит RW в PTE) ведёт к генерации процессором соответствующего исключения (#GP).
Зачастую, решением данной проблемы является временное отключение страничной защиты сбросом бита WP регистра CR0. Это решение имеет место быть, однако применять его нужно с осторожностью, ведь как было отмечено, механизм страничной защиты является основой для многих механизмов ядра. Кроме того, на SMP-системах, поток, выполняющийся на одном из процессоров и там же снимающий бит WP, может быть прерван и перемещён на другой процессор!
Более лучшим и в достаточной степени универсальным, является способ создания временных отображений. В силу особенностей работы MMU, для каждого физического фрейма памяти может быть создано несколько ссылающихся на него описателей, имеющих различные атрибуты. Это позволяет создать для целевой области памяти отображение, доступное для записи. Такой метод используется в проекте Ksplice (форк на github'е). Ниже приведена функция map_writable, которая и создаёт такое отображение:
Использование данной функции позволит создать доступное для записи отображение для любой области памяти. Освобождение созданного таким образом региона осуществляется с использованием функции vfree, аргументом которой должно служить выравненное на границу страницы значение адреса. Дополнительная информация, касающаяся рассматриваемого способа модификации защищённых от записи страниц, представлена в этой статье.
Следующим важным моментом является то, что в ходе модификации посредством патчинга так или иначе затирается часть пролога целевой функции. На это не стоит обращать внимания, если не предполагается использовать эту функцию далее. Однако, если по каким-либо причинам реализуемый целевой функцией алгоритм может быть полезен после патчинга, стоит обеспечить возможность исполнения «старого» кода учитывая «испорченность» существующего пролога.
Далее представлена иллюстрация на которой схематически представлен процесс перехвата функции с сохранением возможности обращения к исходной функциональности.
![image](http://habrastorage.org/r/w1560/files/eb3/cf9/41c/eb3cf941c5b14edfb041ad7a8871eb60.png)
В приведённом примере цифрой 1 отмечена передача управления от целевой функции функции-перехватчику (команда JMP), цифрой 2 — вызов оригинальной функции с использованием сохранённой части пролога (команда CALL), цифрой 3 — возврат управления к части оригинальной функции, не подвергавшейся изменению (команда JMP), и, наконец, цифрой 4 — возврат управления по завершению вызова оригинальной функции из перехватчика (команда RET). Таким образом, обеспечивается возможность использования реализуемых перехватываемой функцией возможностей.
Будем описывать каждую перехватываемую функцию следующей структурой:
Здесь, name — имя перехватываемой функции (имя символа), length — длина затираемой последовательности инструкций пролога, handler — адрес функции-перехватчика, target — адрес самой целевой функции, target_map — адрес доступной для записи проекции целевой функции, origin — адрес функции-переходника, используемой для доступа к исходной функциональности, origin_map — адрес доступной для записи проекции соответствующего переходника, usage — счётчик «залипаний», учитывающий число спящих в перехвате потоков.
Каждая перехватываемая функция должна быть представлена такой структурой. Для этого, дабы упростить регистрацию перехватчиков, используется макрос DECLARE_KHOOK(...), представляемый следующим образом:
Вспомогательные макросы
При загрузке модуля ядра происходит перечисление всех зарегистрированных перехватов (см. khook_for_each), представленных структурами в секции с именем .khook Для каждого из них осуществляется поиск адреса соответствующего символа (см. get_symbol_address), а также настройка вспомогательных элементов, включая создание отображений (см. map_witable):
Важную роль играет функция init_origin_stub, осуществляющая инициализацию и построение переходника, используемого для вызова оригинальной функции после перехвата:
Как видно, для определения количества затираемых при патчинге пролога инструкций используется дизассемблер udis86. В принципе, для этой цели подойдёт любой дизассемблер с функцией определения длины инструкции (т.н. Length-Disassembler Engine, LDE). Я использую для этих целей полноценный дизассемблер udis86, который имеет BSD-лицензию и хорошо зарекомендовал себя. Как только число инструкций определено, происходит их копирование по адресу
Последним элементом, позволяющим сделать модификацию кода ядра безопасной, является механизм stop_machine:
Суть в том, что
Пример использования проиллюстрирую перехватом функции
Для отработки макроса
Таким образом, посредством перехвата реализуется возможность замены функций, а также подмены передаваемых параметров и результата выполнения, что соответствует концепции встраивания, декларирующей возможность переопределения/дополнения механизмов ядра ОС.
Традиционно код модуля ядра, реализующий необходимые для перехвата функций действия, доступен на github.
Данная статья является продолжением анонсированного ранее цикла, посвящённого частным вопросам реализации наложенных средств защиты и, в частности, встраиванию в программные системы.
Целью перехвата любой функции является получение управления в момент её вызова. Дальнейшие действия зависят от конкретных задач. В одних случаях, необходимо заменить системную реализацию алгоритма своей, в других — дополнить. При этом бывает важно оставить возможность использования перехватываемой функции в своих целях.
Традиционным при осуществлении перехвата стал подход при котором используется концепция «обёрток», позволяющая реализовать пре- и пост-обработку с сохранением возможности доступа к исходной функциональности представляемой перехватываемой функцией.
Основой большинства методов перехвата функций является патчинг — модификация кода ядра таким образом, чтобы обеспечить возможность передачи управления на функцию-перехватчик при вызове целевой функции. При этом в силу развитой системы команд архитектуры x86, возможно существование множества вариантов изменения потока выполнения (да, JMP — только один из них: подробнее).
Методика осуществления перехвата
Суть описываемого способа осуществления перехвата будет состоять в том, чтобы модифицировать пролог (начало) целевой функции таким образом, чтобы его выполнение процессором приводило передаче управления на функцию-обработчик.
Другими словами, для каждой целевой функции осуществим модификацию пролога путём записи в её начало команды JMP . Это позволит переключить поток выполнения с целевой функции на соответствующий обработчик.
Например, если до перехвата функция inode_permission имеет вид:
inode_permission:
0xffffffff811c4530 <+0>: nopl 0x0(%rax,%rax,1)
0xffffffff811c4535 <+5>: push %rbp
0xffffffff811c4536 <+6>: test $0x2,%sil
0xffffffff811c453a <+10>: mov 0x28(%rdi),%rax
0xffffffff811c453e <+14>: mov %rsp,%rbp
0xffffffff811c4541 <+17>: jne 0xffffffff811c454a <inode_permission+26>
0xffffffff811c4543 <+19>: callq 0xffffffff811c4470 <__inode_permission>
То после перехвата, пролог этой функции будет выглядеть следующим образом:
inode_permission:
0xffffffff811c4530 <+0>: jmpq 0xffffffffa05a60e0 => ПЕРЕДАЧА УПРАВЛЕНИЯ НА ПЕРЕХВАТЧИК
0xffffffff811c4535 <+5>: push %rbp
0xffffffff811c4536 <+6>: test $0x2,%sil
0xffffffff811c453a <+10>: mov 0x28(%rdi),%rax
0xffffffff811c453e <+14>: mov %rsp,%rbp
0xffffffff811c4541 <+17>: jne 0xffffffff811c454a <inode_permission+26>
0xffffffff811c4543 <+19>: callq 0xffffffff811c4470 <__inode_permission>
Именно записанная поверх оригинальных инструкций пяти-байтовая команда JMP с кодом E9.XX.XX.XX.XX приводит к передачи управления. В этом и состоит основная суть описываемого способа осуществления перехвата. Далее будут рассмотрены некоторые особенности его реализации в ядре Linux.
Особенности осуществления перехвата функций
Как было отмечено, суть патчинга заключается в модификации кода ядра. Основной проблемой, возникающей при этом является то, что запись в страницы памяти, содержащие код невозможна т.к. в архитектуре x86 существует специальный защитный механизм, в соответствии с которым попытка записи в защищённые от записи области памяти может приводить к генерации исключения. Данный механизм носит название «страничной защиты» и является базовым для реализации многих функций ядра, таких, как например COW. Поведение процессора в этой ситуации определяется битом WP регистра CR0, а права доступа к странице описываются в соответствующей ей структуре-описателе PTE. При установленном бите WP регистра CR0 попытка записи в защищённые от записи страницы (cброшен бит RW в PTE) ведёт к генерации процессором соответствующего исключения (#GP).
Зачастую, решением данной проблемы является временное отключение страничной защиты сбросом бита WP регистра CR0. Это решение имеет место быть, однако применять его нужно с осторожностью, ведь как было отмечено, механизм страничной защиты является основой для многих механизмов ядра. Кроме того, на SMP-системах, поток, выполняющийся на одном из процессоров и там же снимающий бит WP, может быть прерван и перемещён на другой процессор!
Более лучшим и в достаточной степени универсальным, является способ создания временных отображений. В силу особенностей работы MMU, для каждого физического фрейма памяти может быть создано несколько ссылающихся на него описателей, имеющих различные атрибуты. Это позволяет создать для целевой области памяти отображение, доступное для записи. Такой метод используется в проекте Ksplice (форк на github'е). Ниже приведена функция map_writable, которая и создаёт такое отображение:
/*
* map_writable creates a shadow page mapping of the range
* [addr, addr + len) so that we can write to code mapped read-only.
*
* It is similar to a generalized version of x86's text_poke. But
* because one cannot use vmalloc/vfree() inside stop_machine, we use
* map_writable to map the pages before stop_machine, then use the
* mapping inside stop_machine, and unmap the pages afterwards.
*
* STOLEN from: https://github.com/jirislaby/ksplice
*/
static void *map_writable(void *addr, size_t len)
{
void *vaddr;
int nr_pages = DIV_ROUND_UP(offset_in_page(addr) + len, PAGE_SIZE);
struct page **pages = kmalloc(nr_pages * sizeof(*pages), GFP_KERNEL);
void *page_addr = (void *)((unsigned long)addr & PAGE_MASK);
int i;
if (pages == NULL)
return NULL;
for (i = 0; i < nr_pages; i++) {
if (__module_address((unsigned long)page_addr) == NULL) {
pages[i] = virt_to_page(page_addr);
WARN_ON(!PageReserved(pages[i]));
} else {
pages[i] = vmalloc_to_page(page_addr);
}
if (pages[i] == NULL) {
kfree(pages);
return NULL;
}
page_addr += PAGE_SIZE;
}
vaddr = vmap(pages, nr_pages, VM_MAP, PAGE_KERNEL);
kfree(pages);
if (vaddr == NULL)
return NULL;
return vaddr + offset_in_page(addr);
}
Использование данной функции позволит создать доступное для записи отображение для любой области памяти. Освобождение созданного таким образом региона осуществляется с использованием функции vfree, аргументом которой должно служить выравненное на границу страницы значение адреса. Дополнительная информация, касающаяся рассматриваемого способа модификации защищённых от записи страниц, представлена в этой статье.
Следующим важным моментом является то, что в ходе модификации посредством патчинга так или иначе затирается часть пролога целевой функции. На это не стоит обращать внимания, если не предполагается использовать эту функцию далее. Однако, если по каким-либо причинам реализуемый целевой функцией алгоритм может быть полезен после патчинга, стоит обеспечить возможность исполнения «старого» кода учитывая «испорченность» существующего пролога.
Далее представлена иллюстрация на которой схематически представлен процесс перехвата функции с сохранением возможности обращения к исходной функциональности.
![image](http://habrastorage.org/files/eb3/cf9/41c/eb3cf941c5b14edfb041ad7a8871eb60.png)
В приведённом примере цифрой 1 отмечена передача управления от целевой функции функции-перехватчику (команда JMP), цифрой 2 — вызов оригинальной функции с использованием сохранённой части пролога (команда CALL), цифрой 3 — возврат управления к части оригинальной функции, не подвергавшейся изменению (команда JMP), и, наконец, цифрой 4 — возврат управления по завершению вызова оригинальной функции из перехватчика (команда RET). Таким образом, обеспечивается возможность использования реализуемых перехватываемой функцией возможностей.
Реализация перехвата функций
Будем описывать каждую перехватываемую функцию следующей структурой:
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).При загрузке модуля ядра происходит перечисление всех зарегистрированных перехватов (см. khook_for_each), представленных структурами в секции с именем .khook Для каждого из них осуществляется поиск адреса соответствующего символа (см. get_symbol_address), а также настройка вспомогательных элементов, включая создание отображений (см. map_witable):
static int init_hooks(void)
{
khookstr_t * s;
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) {
atomic_inc(&s->usage);
continue;
}
}
}
debug("Failed to initalize \"%s\" hook\n", s->name);
}
/* apply patches */
stop_machine(do_init_hooks, NULL, NULL);
return 0;
}
Важную роль играет функция init_origin_stub, осуществляющая инициализацию и построение переходника, используемого для вызова оригинальной функции после перехвата:
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_Ijmp || ud.mnemonic == UD_Iint3) {
debug("It seems that \"%s\" is not a hooking virgin\n", s->name);
return -EINVAL;
}
#define JMP_INSN_LEN (1 + 4)
s->length += ud_insn_len(&ud);
if (s->length >= JMP_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;
}
Как видно, для определения количества затираемых при патчинге пролога инструкций используется дизассемблер udis86. В принципе, для этой цели подойдёт любой дизассемблер с функцией определения длины инструкции (т.н. Length-Disassembler Engine, LDE). Я использую для этих целей полноценный дизассемблер udis86, который имеет BSD-лицензию и хорошо зарекомендовал себя. Как только число инструкций определено, происходит их копирование по адресу
origin_map
, что соответствует RW-проекции 32-байтного переходника origin
. В завершении, после сохранённых команд с использованием x86_put_jmp вставляется команда, возвращающая управление на оригинальной код целевой функции, не подвергшийся изменению.Последним элементом, позволяющим сделать модификацию кода ядра безопасной, является механизм stop_machine:
#include <linux/stop_machine.h>
int stop_machine(int (*fn)(void *), void *data, const struct cpumask *cpus)
Суть в том, что
stop_machine
осуществляет выполнение функции fn
с заданным набором активных в момент выполнения процессоров, что задаётся соответствующей маской cpumask. Именно это позволяет использовать данный механизм для осуществления модификации кода ядра, т.к. задание соответствующей маски автоматически исключает необходимость отслеживания тех потоков ядра, выполнение которых может затрагивать модифицируемый код.Использование
Пример использования проиллюстрирую перехватом функции
inode_permission
. С учётом рассмотренных макросов, последовательность перехвата функции будет следующей:#include <linux/fs.h>
DECLARE_KHOOK(inode_permission);
int khook_inode_permission(struct inode * inode, int mode)
{
int result;
KHOOK_USAGE_INC(inode_permission);
debug("%s(%pK,%08x) [%s]\n", __func__, inode, mode, current->comm);
result = KHOOK_ORIGIN(inode_permission, inode, mode);
debug("%s(%pK,%08x) [%s] = %d\n", __func__, inode, mode, current->comm, result);
KHOOK_USAGE_DEC(inode_permission);
return result;
}
Для отработки макроса
DECLARE_KHOOK(...)
необходимо, чтобы существовал прототип перехватываемой функции (linux/fs.h
для inode_permission
). Далее, в реализации функции-перехватчика (имеющей префикс khook_
), можно делать что угодно. Для примера, я вывожу отладочное сообщение до и после вызова оригинальной функции inode_permission
.Таким образом, посредством перехвата реализуется возможность замены функций, а также подмены передаваемых параметров и результата выполнения, что соответствует концепции встраивания, декларирующей возможность переопределения/дополнения механизмов ядра ОС.
Традиционно код модуля ядра, реализующий необходимые для перехвата функций действия, доступен на github.