Вбудовування в ядро Linux: перехоплення функцій

Перехоплення функцій ядра є базовим методом, що дозволяє перекрити/доповнювати різні його механізми. Виходячи з того, що ядро Linux майже повністю написано на мові C, за винятком невеликих архітектурно-залежних частин, можна стверджувати, що для здійснення вбудовування в більшість з компонентів ядра досить мати можливість перехоплення відповідних функцій.

Дана стаття є продовженням анонсованого раніше циклу, присвяченого приватним питань реалізації накладених засобів захисту і, зокрема, запровадження в програмні системи.



Метою перехоплення будь-якої функції є одержання управління в момент її виклику. Подальші дії залежать від конкретних завдань. В одних випадках, необхідно замінити системну реалізацію алгоритму своєї, в інших — доповнити. При цьому буває важливо залишити можливість використання перехватываемой функції в своїх цілях.

Традиційним при здійсненні перехоплення став підхід при якому використовується концепція «обгорток», що дозволяє реалізувати пре — і пост-обробку із збереженням можливості доступу до вихідної функціональності представленої перехватываемой функцією.

Основою більшості методів перехоплення функцій є патчінг — модифікація коду ядра таким чином, щоб забезпечити можливість передачі управління на функцію-перехоплювач при виклику цільової функції. При цьому в силу розвиненої системи команд 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 спроба запису в захищені від запису сторінки (біт скинутий 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 version of generalized 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

У наведеному прикладі цифрою 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.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.