[Перевод] Пособие по программированию модулей ядра Linux. Ч.4
Перед вами очередной фрагмент последней версии руководства по написанию модулей ядра от 2 июля 2022 года. Тема этой части — системные вызовы. В ней вы познакомитесь с этим понятием на примере создания собственной функции для открытия файлов, которая будет подменять собой исходную sys_open
, а также следить за конкретным пользователем, информируя нас об открываемых им файлах.
Ссылки на предыдущие части руководства:
▍ 10. Системные вызовы
До этого момента мы просто использовали отлаженные механизмы ядра для регистрации файлов /proc
и обработчиков устройств. И это отличное решение, когда вы хотите сделать нечто стандартное, что предполагали программисты ядра, например написать драйвер устройства. Но как быть, если вы планируете сделать что-то необычное, каким-то образом изменить поведение системы? Здесь вам придется действовать полностью самостоятельно.
И если вы пойдете рисковым путем, не задействовав виртуальную машину, то программирование ядра уже может стать весьма опасным занятием. В процессе написания примера, который вы увидите ниже, я убил системный вызов open()
и в итоге не смог открывать какие-либо файлы, запускать программы и даже выключить систему. Пришлось перезапускать виртуальную машину. Никакие важные файлы не пострадали, но если бы я делал то же самое в реальной критически важной системе, то такой исход оказался бы вполне вероятен. Чтобы оградить себя от возможной потери каких-либо файлов, даже в рамках тестовой среды, рекомендую до выполнения insmod
и rmmod
выполнять sync
.
Забудьте о файлах /proc
, забудьте о файлах устройств — это лишь мелкие детали в необъятном пространстве вселенной. Реальным механизмом в контексте взаимодействия с ядром, тем, который используют все процессы, являются системные вызовы. Когда процесс запрашивает у ядра операцию (например, открытие файла, разветвление на новый процесс или выделение дополнительной памяти), используется именно этот механизм. Если вы хотите изменить поведение ядра, то вмешательство производится как раз в него. Кстати, посмотреть, какие системные вызовы использует программа, можно так:
strace
Как правило, процесс не должен иметь возможности обращаться к ядру, то есть он не может обращаться к его памяти и вызывать его функции. Это обусловлено аппаратной спецификой ЦПУ (именно поэтому мы говорим «защищенный режим» или «защита страниц»).
И системные вызовы являются в этом общем правиле исключением. Технически это происходит так — процесс заполняет регистр нужными значениями, после чего вызывает особую инструкцию, которая переключается на ранее определенную область ядра (естественно, эта область доступна пользовательским процессам только для чтения, но не для записи). В случае процессоров Intel это происходит посредством прерывания 0х80
. Аппаратное обеспечение знает, что при переходе в эту область вы выходите из ограниченного пользовательского режима, начиная действовать уже в юрисдикции ядра, в связи с чем для вас открываются все двери.
Область ядра, в которую может перескочить процесс, называется system_call
. Процедура в этой области проверяет номер системного вызова, сообщая ядру о том, какое действие процесс запросил. Затем она просматривает таблицу системных вызовов (sys_call_table
) в поиске адреса нужной функции ядра. Далее она эту функцию вызывает, и после того, как та возвращает результат, выполняет ряд системных проверок и делает возврат процессу (или к другому процессу, если время изначального истекло). Прописан весь этот код в файле arch/$(architecture)/kernel/entry.S
после строки ENTRY(system_call)
.
Итак, если мы хотим изменить способ работы определенного системного вызова, то нам нужно написать собственную функцию для его реализации (обычно для этого добавляется собственный код, после чего вызывается оригинальная функция), а затем перевести указатель в sys_call_table
на нашу новую функцию. Поскольку позднее эта функция может быть удалена, важно, чтобы cleanup_module
восстанавливала таблицу в исходное состояние.
Для изменения содержимого sys_call_table
необходимо взять во внимание регистр управления. Это регистр процессора, который изменяет или управляет его общим функционированием. В архитектуре x86 у регистра cr0
есть различные флаги управления, которые изменяют основные операции процессора. Среди них есть флаг WP
, который отвечает за защиту от записи. Если он установлен, процессор запрещает выполнение записи в разделы только для чтения. Следовательно, прежде чем изменять sys_call_table
, нам нужно этот флаг отключить.
Начиная с Linux v5.3, функцию write_cr0
использовать нельзя из-за чувствительных бит cr0
, представляющих угрозу безопасности. С их помощью атакующий может производить запись в регистры управления, отключая защиты ЦПУ, например защиту от записи. В итоге для обхода этого ограничения необходимо предоставить собственную подпрограмму ассемблера.
Однако символ sys_call_table
с целью предотвращения подобного трюка является неэкспортируемым. Но у нас все же есть пара способов для его получения, а именно ручной поиск символов и kallsyms_lookup_name
. Здесь мы рассмотрим использование обоих, в зависимости от версии ядра.
В ядре используется механизм Control-Flow Integrity (CFI), предназначенный для предотвращения возможного перенаправления исполнения кода атакующим. Он позволяет гарантировать направление косвенных вызовов к ожидаемым адресам, а также неизменность возвращаемых адресов. Начиная с Linux v5.7, в ядре пропатчили ряд методов Control-Flow Enforcement (CET) для архитектуры x86, и некоторые конфигурации GCC, например GCC 9 и 10 версии, будут идти с CET (опция -fcf-protection
) по умолчанию. Использование этого GCC для компиляции ядра при отключенном Retpoline может привести к активации в ядре функционала CET. Проверить, включена ли опция -fcf-protection
, можно следующей командой:
$ gcc -v -Q -O2 --help=target | grep protection
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-linux-gnu/9/lto-wrapper
...
gcc version 9.3.0 (Ubuntu 9.3.0-17ubuntu1~20.04)
COLLECT_GCC_OPTIONS='-v' '-Q' '-O2' '--help=target' '-mtune=generic' '-march=x86-64'
/usr/lib/gcc/x86_64-linux-gnu/9/cc1 -v ... -fcf-protection ...
GNU C17 (Ubuntu 9.3.0-17ubuntu1~20.04) version 9.3.0 (x86_64-linux-gnu)
...
Но CET не должен быть включен в ядре, поскольку это может нарушить работу Kprobes и bpf. По этой причине, начиная с v5.11, данный функционал был отключен. Поэтому, чтобы у нас гарантированно работал ручной поиск символов, мы используем версии ядра до v5.4.
К сожалению, начиная с Linux v5.7, kallsyms_lookup_name
также не экспортируется, и получить адрес этой функции можно лишь обходным путем. Если включена опция CONFIG_KPROBES
, можно извлечь этот адрес, используя Kprobes для динамического проникновения в определенную подпрограмму ядра. Kprobe вставляет точку останова на входе функции, заменяя первые байты просматриваемой инструкции. Когда ЦПУ достигает этой точки останова, регистры сохраняются, и управление переходит Kprobes. Он передает адреса сохраненных регистров и структуры Kprobe заданному нами обработчику, после чего его запускает. Kprobes можно зарегистрировать по имени символа или адресу. При использовании имени символа адрес будет обрабатываться ядром.
В противном случае указать адрес sys_call_table
из /proc/kallsyms
и /boot/System.map
в параметре sym
. Вот пример использования /proc/kallsyms
:
$ sudo grep sys_call_table /proc/kallsyms
ffffffff82000280 R x32_sys_call_table
ffffffff820013a0 R sys_call_table
ffffffff820023e0 R ia32_sys_call_table
$ sudo insmod syscall.ko sym=0xffffffff820013a0
При использовании адреса из /boot/System.map
будьте внимательны к KASLR
(рандомизация адресного пространства ядра). Этот механизм может рандомизировать адреса кода ядра и данных при каждой загрузке. К примеру, статический адрес, указанный в /boot/System.map
, сдвигается на некоторое случайное значение.
Задача KASLR
— защищать пространство ядра от атак. В случае отсутствия этого механизма атакующему было бы проще отыскать фиксированный целевой адрес, после чего с помощью возвратно-ориентированного программирования вставить вредоносный код для выполнения или получения нужных данных по фиктивному указателю. KASLR
противостоит подобным атакам, не позволяя атакующему прямиком узнать целевой адрес. Хотя метод подбора здесь все еще может сработать. Если адрес символа в /proc/kallsyms
отличается от адреса в /boot/System.map
, ядро активирует KASLR
.
$ grep GRUB_CMDLINE_LINUX_DEFAULT /etc/default/grub
GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"
$ sudo grep sys_call_table /boot/System.map-$(uname -r)
ffffffff82000300 R sys_call_table
$ sudo grep sys_call_table /proc/kallsyms
ffffffff820013a0 R sys_call_table
# Перезагрузка
$ sudo grep sys_call_table /boot/System.map-$(uname -r)
ffffffff82000300 R sys_call_table
$ sudo grep sys_call_table /proc/kallsyms
ffffffff86400300 R sys_call_table
Если KASLR
активна, то после каждой перезагрузки необходимо уделять внимание адресу из /proc/kallsyms
. Поэтому для использования адреса из /boot/System.map
нужно будет убедиться, что KASLR
отключена. Отключить его можно, добавив при очередной загрузке параметр nokaslr
:
$ grep GRUB_CMDLINE_LINUX_DEFAULT /etc/default/grub
GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"
$ sudo perl -i -pe 'm/quiet/ and s//quiet nokaslr/' /etc/default/grub
$ grep quiet /etc/default/grub
GRUB_CMDLINE_LINUX_DEFAULT="quiet nokaslr splash"
$ sudo update-grub
Дополнительно по этой теме читайте:
Приведенный здесь исходный код является примером подобного модуля ядра. Мы хотим реализовать «слежку» за определенным пользователем и выводить (pr_info()
) сообщение при каждом открытии им файла. С этой целью мы заменяем системный вызов sys_open
собственной функцией our_sys_open
. Эта функция проверяет uid текущего процесса, и если оно совпадает с uid, за которым мы следим, вызывает pr_info()
для отображения имени открываемого файла. Затем она в любом случае вызывает оригинальную функцию sys_open
с теми же параметрами, чтобы уже реально открыть файл.
Функция init_module
заменяет нужную запись в sys_call_table
и сохраняет оригинальный указатель в переменной. В последствии функция cleanup_module
использует эту переменную для восстановления исходного состояния таблицы. Это опасный подход, поскольку есть вероятность, что два модуля изменят один и тот же системный вызов. Представьте, что у нас есть два модуля, A и B. У А системным вызовом для открытия файла будет A_open
, а у В им будет B_open
. Теперь, при внедрении А в ядро соответствующий системный вызов будет заменен на A_open
, который по завершению вызовет sys_open
. Далее в ядро внедряется В, заменяя системный вызов А на B_open
, который по завершению вызовет тот самый A_open
, являющийся для него оригинальным.
Теперь, если первым будет удален В, то все нормально — он просто восстановит системный вызов на A_open
, который вызывает оригинал. Но вот если сначала удалить А, а затем В, то в системе произойдет сбой. Удаление А приведет к восстановлению исходного системного вызова, sys_open
, исключив из процесса B. Затем, когда будет удаляться В, он постарается восстановить системный вызов на тот, который считает исходным, то есть A_open
, но его в памяти уже не окажется.
На первый взгляд эту проблему можно разрешить, проверив, совпадает ли системный вызов с нашей функцией открытия — если да, то просто его не менять (чтобы В не трогал системный вызов при удалении). Но это создаст еще большую проблему. При удалении А увидит, что системный вызов был заменен на B_open
и больше не указывает на A_open
, а значит не станет восстанавливать его на sys_open
, пока не будет удален из памяти. К сожалению, в итоге В по-прежнему будет пытаться вызвать A_Open
, которого больше нет, так что даже без удаления В система все равно даст сбой.
Заметьте, что все описанные проблемы делают перехват системных вызовов нецелесообразным для использования в продакшн-среде. И для того, чтобы оградить людей от совершения потенциально вредных действий, sys_call_table
больше не экспортируется. То есть, если вы хотите сделать что-то большее, нежели просто выполнить этот пример, то вам потребуется пропатчить ядро, чтобы вернуть поддержку экспорта sys_call_table
.
/*
* syscall.c
*
* Пример перехвата системного вызова.
*
* Отключает защиту страниц на уровне процессора путем изменения
* 16 бита в регистре cr0 (возможно, относится только к Intel).
*
* Основан на примере Питера Джея Зальцмана и
* https://bbs.archlinux.org/viewtopic.php?id=139406
*/
#include
#include
#include
#include /* Будет содержать параметры. */
#include /* Список системных вызовов. */
#include
/* Для текущей структуры (процесса). Необходимы для понимания, кто
* является текущим пользователем.
*/
#include
#include
/* По ходу изменения ядра изменяется и способ обращения к "sys_call_table"
* - до v5.4 : ручной поиск символов
* - с v5.5 по v5.6: использование kallsyms_lookup_name()
* - v5.7+ : Kprobes либо определенный параметр модуля ядра
*/
/* В Linux v5.11+ внутренние вызовы ядра к ksys_close() были удалены.
*/
#if (LINUX_VERSION_CODE < KERNEL_VERSION(5, 7, 0))
#if LINUX_VERSION_CODE <= KERNEL_VERSION(5, 4, 0)
#define HAVE_KSYS_CLOSE 1
#include /* Для ksys_close() */
#else
#include /* Для kallsyms_lookup_name */
#endif
#else
#if defined(CONFIG_KPROBES)
#define HAVE_KPROBES 1
#include
#else
#define HAVE_PARAM 1
#include /* Для sprint_symbol */
/* Адрес sys_call_table, который можно получить поиском по
* "/boot/System.map" или "/proc/kallsyms". В ядре v5.7+, когда
* CONFIG_KPROBES отсутствует, можно вводить этот параметр, иначе модуль
* будет производить поиск по всей памяти.
*/
static unsigned long sym = 0;
module_param(sym, ulong, 0644);
#endif /* CONFIG_KPROBES */
#endif /* Version < v5.7 */
static unsigned long **sys_call_table;
/* UID, за которым мы хотим следить – будет заполняться из командной строки. */
static int uid;
module_param(uid, int, 0644);
/* Указатель на исходный системный вызов. Мы сохраняем его, а не
* вызываем исходную функцию (sys_open), так как кто-то другой мог
* заменить этот системный вызов до нас. Заметьте, что это не гарантирует
* 100% безопасность, поскольку, если до этого sys_open уже был
* заменен другим модулем, то внедрение нашего приведет
* к вызову функции в том модуле - а он может быть уже удален.
*
* Еще одна причина в том, что мы не можем получить sys_open, поскольку
* это статическая переменная, и она не экспортируется.
*/
static asmlinkage int (*original_call)(const char *, int, int);
/* Функция, которой мы заменяем sys_open. Для нахождения точного прототипа
* с числом и типом аргументов сначала мы находим исходную функцию
* (в fs/open.c).
*
* В теории это означает, что мы привязаны к текущей версии ядра. На
* практике же системные вызовы почти никогда не меняются (это бы внесло
* беспорядок и потребовало перекомпиляции программ, так как системные вызовы
* являются интерфейсом между ядром и процессами).
*/
static asmlinkage int our_sys_open(const char *filename, int flags, int mode)
{
int i = 0;
char ch;
/* В случае соответствия сообщить об открытом файле. */
pr_info("Opened file by %d: ", uid);
do {
get_user(ch, (char __user *)filename + i);
i++;
pr_info("%c", ch);
} while (ch != 0);
pr_info("\n");
/* Вызов исходной sys_open – иначе мы потеряем возможность открывать
* файлы.
*/
return original_call(filename, flags, mode);
}
static unsigned long **aquire_sys_call_table(void)
{
#ifdef HAVE_KSYS_CLOSE
unsigned long int offset = PAGE_OFFSET;
unsigned long **sct;
while (offset < ULLONG_MAX) {
sct = (unsigned long **)offset;
if (sct[__NR_close] == (unsigned long *)ksys_close)
return sct;
offset += sizeof(void *);
}
return NULL;
#endif
#ifdef HAVE_PARAM
const char sct_name[15] = "sys_call_table";
char symbol[40] = { 0 };
if (sym == 0) {
pr_alert("For Linux v5.7+, Kprobes is the preferable way to get "
"symbol.\n");
pr_info("If Kprobes is absent, you have to specify the address of "
"sys_call_table symbol\n");
pr_info("by /boot/System.map or /proc/kallsyms, which contains all the "
"symbol addresses, into sym parameter.\n");
return NULL;
}
sprint_symbol(symbol, sym);
if (!strncmp(sct_name, symbol, sizeof(sct_name) - 1))
return (unsigned long **)sym;
return NULL;
#endif
#ifdef HAVE_KPROBES
unsigned long (*kallsyms_lookup_name)(const char *name);
struct kprobe kp = {
.symbol_name = "kallsyms_lookup_name",
};
if (register_kprobe(&kp) < 0)
return NULL;
kallsyms_lookup_name = (unsigned long (*)(const char *name))kp.addr;
unregister_kprobe(&kp);
#endif
return (unsigned long **)kallsyms_lookup_name("sys_call_table");
}
#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 3, 0)
static inline void __write_cr0(unsigned long cr0)
{
asm volatile("mov %0,%%cr0" : "+r"(cr0) : : "memory");
}
#else
#define __write_cr0 write_cr0
#endif
static void enable_write_protection(void)
{
unsigned long cr0 = read_cr0();
set_bit(16, &cr0);
__write_cr0(cr0);
}
static void disable_write_protection(void)
{
unsigned long cr0 = read_cr0();
clear_bit(16, &cr0);
__write_cr0(cr0);
}
static int __init syscall_start(void)
{
if (!(sys_call_table = aquire_sys_call_table()))
return -1;
disable_write_protection();
/* Отслеживание исходной функции открытия. */
original_call = (void *)sys_call_table[__NR_open];
/* Использование вместо нее собственной функции. */
sys_call_table[__NR_open] = (unsigned long *)our_sys_open;
enable_write_protection();
pr_info("Spying on UID:%d\n", uid);
return 0;
}
static void __exit syscall_end(void)
{
if (!sys_call_table)
return;
/* Восстановление исходного системного вызова. */
if (sys_call_table[__NR_open] != (unsigned long *)our_sys_open) {
pr_alert("Somebody else also played with the ");
pr_alert("open system call\n");
pr_alert("The system may be left in ");
pr_alert("an unstable state.\n");
}
disable_write_protection();
sys_call_table[__NR_open] = (unsigned long *)original_call;
enable_write_protection();
msleep(2000);
}
module_init(syscall_start);
module_exit(syscall_end);
MODULE_LICENSE("GPL");
▍ Продолжение
Следующая часть будет посвящена блокированию процессов и потоков, а также работе с различными видами блокировок и атомарными операциями.
▍ Предыдущие части
Конкурс статей от RUVDS.COM. Три денежные номинации. Главный приз — 100 000 рублей.