Перехват системных вызовов Linux с помощью LSM

в 8:45, , рубрики: C, hooks, linux kernel, lsm, modules, информационная безопасность, Разработка под Linux, метки:

Перехват системных вызовов Linux с помощью LSM - 1

Недавно поступили такие задачи: собрать ядро Linux, написать для него модуль и с его помощью перехватывать системные вызовы. И если первые две я выполнил без проблем, то в процессе выполнения третьей у меня возникло впечатление, что работа с системными вызовами вышла из моды лет 10 назад.

Периодически я находил в интернете статьи, которые были близкими к тому, что я искал, некоторые были даже очень хорошо написаны, но у всех был существенный недостаток — они устарели.

Начальные условия

  • 4 ядра процессора Intel Core i7
  • 4 Гб оперативной памяти + 4 Гб swap
  • Ubuntu 16.10 x64 на виртуальной машине VirtualBox 5.1.10
  • Ядро Linux 4.9.0
  • gcc 6.2.0

Для редактирования конфигурации ядра в псевдографическом режиме нужен ncurses:

sudo apt-get update
sudo apt-get install libncurses5-dev

Сборка чистого ядра

Я рекомендую собрать чистое ядро прежде, чем начать разработку модулей. На это есть 2 причины:

  1. Первая сборка ядра — довольно продолжительный процесс. Чаще всего он длится от 20 минут до 3 часов. Если же провести сборку заранее, вы получите большую часть бинарников ядра, которые не будут нуждаться в перекомпиляции. Это позволит полностью сосредоточиться на разработке модуля, не мучаясь в ожидании ответа на вопрос “Запустится ли мой первый Hello World?”
  2. Успешно собрав чистое ядро, вы убедитесь, что на этом этапе нет проблем и что можно приступать к следующему. Иногда загрузка со свежесобранным ядром может быть неуспешной, и, если вы собирали его вместе с модулем, сложно будет понять, что именно положило систему.

Итак, сборка ядра:

  1. Скачиваем архив с исходниками:
    wget https://cdn.kernel.org/pub/linux/kernel/v4.x/linux-x.x.x.tar.xz

    где x.x.x — версия ядра.

    Либо можно скачать архив руками с kernel.org

  2. Извлекаем данные из архива:
    tar -xpJf linux-x.x.x.tar.xz

  3. Переходим в только что распакованную папку:
    cd linux-x.x.x

  4. Генерируем конфигурацию ядра по умолчанию:
    make defconfig

    Для продвинутых:

    make menuconfig

    Откроется псевдографический интерфейс конфигурации ядра. В большинстве опций разобраться не сложно, но без понимания каждого изменяемого параметра очень легко всё поломать. Для первой сборки я рекомендую использовать всё же параметры по умолчанию.

  5. Запускаем непосредственно сборку ядра и модулей:
    make && make modules

    Сборка будет длиться от 20 минут до 3х часов.

    Лайфхак:

    make -j x && make modules -j x

    Где x — количество ядер процессора + 1. То есть в моём случае x = 5.
    Такое значение рекомендуют установить во всех руководствах, но на самом деле значение можно установить любое. Я решил “увеличить количество ядер вдвое”, то есть запустить сборку c параметром -j 9. Это не ускоряет сборку в 2 раза, но увеличивает конкурентоспособность процессов сборки по отношения ко всем другим процессам в системе.

    Кроме того, в системном мониторе(gnome-system-monitor) всем make-процессам я установил максимальный приоритет. Система после этого буквально зависла, но сборка прошла за 6 минут. Используйте этот метод на свой страх и риск.

    После успешной сборки нужно установить всё то, что мы собрали. Это требует root-прав.

  6. Установка заголовков:
    sudo make headers_install

  7. Установка модулей:
    sudo make modules_install

  8. Установка непосредственно ядра:
    sudo make install

  9. Команды установки должны сгенерировать начальный RAM-диск и обновить grub. Если вдруг начальный RAM-диск не сгенерировался — система с новым ядром не запустится.

    Проверить это можно по наличию файла "/boot/initrd.img-x.x.x" (x.x.x — версия ядра)
    Если файла не обнаружилось — генерируем его руками:

    sudo update-initramfs –c –k x.x.x

  10. Обновляем загрузчик grub:
    sudo update-grub

Готово! После перезапуска система запустится с новым ядром. Проверить текущую версию ядра:

uname -r

Если вдруг что-то пошло не так, и система не загружается с новым ядром, перезагрузите компьютер, в меню grub перейдите в advanced options и выберите другую версию ядра(ту, под которой вы загружались раньше, обычно у версий по умолчанию добавляют суффикс -general)

Создание модуля

Создание модуля ядра во многом напоминает написание обычной пользовательской программы на C, кроме некоторых отличий связанных с ядром:

  • Ядро не имеет доступа к стандартным библиотекам языка C. Причина этого – скорость выполнения и объем кода. Часть функций, однако, можно найти в исходниках ядра. Например, обычные функции работы со строками описаны в файле lib/string.c
  • Отсутствие защиты памяти. Если обычная программа предпринимает попытку некорректного обращения к памяти, ядро может аварийно завершить процесс. Если ядро предпримет попытку некорректного обращения к памяти, результаты будут менее контролируемыми. К тому же ядро не использует замещение страниц: каждый байт, используемый в ядре, – это один байт физической памяти.
  • В ядре нельзя использовать вычисления с плавающей точкой. Активизация режима вычислений с плавающей точкой требует сохранения и проставления регистров устройства поддержки вычислений с плавающей точкой, помимо других рутинных операций.
  • Фиксированный стек(причём довольно небольшой). Именно поэтому не рекомендуется использовать рекурсию в ядре.

Hello world!

Давайте на конкретном примере рассмотрим “Hello world” в виде модуля ядра. Создадим файл hello.c в любой удобной для вас папке:

// hello.c
#include <linux/module.h>
#include <linux/kernel.h>

static int  __init myinit(void)
{
   printk("%sn","<my_tag> hello world");

   return 0;
}

static void __exit myexit(void) {}

module_init(myinit);
module_exit(myexit);

MODULE_LICENSE("GPL");

Обычная пользовательская программа начинается с вызова функции main() и работает, пока не возвратит системе какое-то значение.

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

Функции, которые обрабатывают эти события — соответственно

static int __init myinit(void)
static void __exit myexit(void)

Они отмечены макросами __init, __exit и зарегистрированы с помощью module_init и module_exit как обработчики событий. Название этих функций может быть любым, но не должно конфликтовать с другими функциями в ядре.

Поскольку ядро не использует стандартную библиотеку C, мы не можем использовать stdio.h. Вместо этого мы подключаем файл kernel.h, в котором реализована функция printk. Эта функция аналогична printf с тем лишь отличием, что выводит сообщения не в окно терминала, а в системный лог (/var/log/syslog).

В этот лог пишется очень много сообщений со всей системы, поэтому наши нужно пометить каким-то оригинальным тегом, чтобы потом с помощью утилиты grep можно было выделить только сообщения нашего модуля.

Ещё одна непонятная строчка — MODULE_LICENSE(«GPL»);

Она указывает, что наш модуль соответствует лицензии GPL. Без этого часть возможностей внутри ядра будет недоступна.

Сборка

Для того чтобы собрать этот модуль в той же папке, где лежит исходный код модуля, создадим Makefile:

# указываем путь к недавно собранному ядру
KERNEL_PATH = /path-to-your-kernel/linux-x.x.x

# перечисляем файлы, которые будут собираться в файлы модулей
obj-m += hello.o

all:
# запуск make с параметром -C указывает, что мы запускаем сборку в окружении
# KERNEL_PATH. Это нужно для того, чтобы компилятор подтянул зависимости
# из исходников ядра
# SUBDIRS - это место, в котором будут лежать результаты сборки, 
# в данном случае - текущая папка
	make -C $(KERNEL_PATH) SUBDIRS=$(PWD) modules
# Удаление ненужных файлов, которые создались в процессе сборки
	make clean
# описание файлов, которые можно удалить после сборки
clean:
	rm -f *.o *.mod* Module.symvers modules.order

После создания Makefile переходим непосредственно к сборке:

make

Через пару секунд в нашей папке появится файл hello.ko — готовый скомпилированный модуль.

Загрузка и выгрузка

Существует 2 способа загрузки модуля в ядро:

  1. Сборка модуля вместе с ядром. В таком случае загрузка модуля происходит как часть запуска системы, а сам модуль становится частью кода ядра.
  2. Динамическая загрузка в уже запущенной системе. Вышеописанный способ создания модуля предполагает именно такой способ загрузки. В этом случае загрузка модуля больше похожа на запуск обычной пользовательской программы.

Загрузить модуль:

sudo insmod hello.ko

Команда insmod загружает модуль в пространство ядра, тем самым вызывая функцию инициализации.

После этого модуль попадает в список загруженных. Проверить это можно командой lsmod:

image

В функции инициализации мы добавили вызов printk, который выводит в системный лог наше сообщение.

Для просмотра системного лога существует утилита dmesg:

dmesg | grep '<my_tag>'

Вышеуказанная команда выведет

<my_tag> hello world

После того, как мы загрузили модуль, он так и останется висеть в ядре до тех пор, пока его не выгрузят. Чтобы сделать это:

sudo rmmod hello.ko

Эта команда вызовет обработчик события __exit, но поскольку у нас там пустая функция, кроме выгрузки модуля из ядра ничего не произойдёт.

Лайфхак

Для того, чтобы каждый раз не вводить 2 команды для загрузки и выгрузки модуля во время отладки, в функции инициализации возвращают значение -1. Такой модуль при попытке загрузки выводит в терминал ошибку, после чего прекращает работу, но при этом функция инициализации отрабатывает полностью и корректно, превращаясь, по сути, в аналог функции main() пользовательских программ.

static int  __init myinit(void)
{
   printk("%sn","<my_tag> hello world");

   return -1;
}

Далее мы рассмотрим первый способ загрузки модуля, а также то, ради чего изначально писалась эта статья.

Перехват системных вызовов

Небезопасный способ

Когда-то давно, ещё до ядра версии 2.6, для того, чтобы перехватить системный вызов, писали функцию-хук, которая её заменяла: выполняла другой код + вызывала непосредственно сам syscall(чтобы не нарушить работоспособность системы).

Так как каждый системный вызов подобно функции имеет свой адрес, а в Linux есть специальная таблица где эти адреса хранятся, задача сводилась к тому, чтобы в этой самой таблице заменить адрес системного вызова на адрес нашей функции.

Позже, разработчики Linux пытались устранить возможность такого способа, но до сих пор существуют хаки, которые позволяют реализовать этот метод.

Тем не менее, он очень небезопасный, и я не буду его описывать. Тем более, что для решения задачи в то же время придумали более изящное и безопасное решение.

LSM

LSM — это фреймворк для разработки модулей безопасности ядра. Он был создан для того, чтобы расширить стандартную модель безопасности DAC, сделать её более гибкой. Этот фреймворк использует известный модуль безопасности SELinux, а также ещё несколько других, встроенных в ядро.

Самое ценное для нас в данном фреймворке то, что он реализован через набор заранее предустановленных в ядро хуков(по сути, тот способ, который я описывал выше, но безопасный, потому что ядро заранее рассчитано на наличие таких хуков).

LSM позволяет вставлять в код своих хуков вызов пользовательских, что позволяет безопасно работать с системными вызовами без изменения таблицы символов.

Всё предельно просто. Рассмотрим пример создания модуля безопасности foobar, который перехватывает системный вызов mk_dir.

Написание кода

  1. Находим в исходниках ядра папку security, создаём в ней папку для нашего модуля, а в ней — его исходный код foobar.c:
    // /security/foobar/foobar.c
    //---INCLUDES
    #include <linux/module.h>
    #include <linux/lsm_hooks.h>
    
    //---HOOKS
    //mkdir hook
    static int foobar_inode_mkdir(struct inode *dir, struct dentry *dentry, umode_t mask)
    {
        	printk("%sn","<my_tag> mkdir hook");
        	return 0;
    }
    
    //---HOOKS REGISTERING
    static struct security_hook_list foobar_hooks[] =
    {
        	LSM_HOOK_INIT(inode_mkdir, foobar_inode_mkdir),
    };
    
    //---INIT
    void __init foobar_add_hooks(void)
    {
        	security_add_hooks(foobar_hooks, ARRAY_SIZE(foobar_hooks));
    }
    

    Файл lsm_hooks.h содержит заголовки тех самых предустановленных хуков, LSM_HOOK_INIT регистрирует соответствие foobar_inode_mkdir() хуку inode_mkdir(), а security_add_hooks() добавляет нашу функцию в общий список пользовательских хуков LSM.

    Таким образом, при каждом вызове mkdir будет вызываться наша функция foobar_inode_mkdir().

  2. Добавляем заголовок нашей функции в файл “/include/linux/lsm_hooks.h”:
    #ifdef CONFIG_SECURITY_FOOBAR
          	extern void __init foobar_add_hooks(void);
    #else
          	static inline void __init foobar_add_hooks(void) { }
    #endif
    

    Все вызовы происходят в исходном файле security.c (далее), этим шагом мы оповещаем его о существовании нашей функции.

  3. В файле “/security/security.c” находим функцию “int __init security_init(void)” и добавляем в её тело следующий вызов:
    foobar_add_hooks();

Всё, зависимости в коде настроены корректно. Осталось только оповестить конфигурационные файлы ядра о том, что мы хотим собрать его вместе с нашим модулем.

Конфигурация сборки

  1. В папке с нашим модулем(/security/foobar/) создадим файл Kconfig:
    config SECURITY_FOOBAR
    bool "FooBar security module"
          	default y
    help
          	Any help text here
    

    Это создаст пункт меню с нашим модулем.

  2. Откроем файл /security/Kconfig и добавим следующий текст сразу за строчкой “menu «Security options»":
    source security/foobar/Kconfig
    

    Это добавит наш пункт меню в глобальное меню настроек ядра.

  3. Создадим Makefile в папке с нашим модулем:
    obj-$(CONFIG_SECURITY_FOOBAR) += foobar.o
    

  4. Откроем Makefile всего раздела безопасности(/security/Makefile) и добавим в него следующие строчки(по аналогии с такими же строчками для других модулей):
    subdir-$(CONFIG_SECURITY_FOOBAR) += foobar
    obj-$(CONFIG_SECURITY_FOOBAR) += foobar/
    

  5. Запустим конфигурирование в псевдографическом режиме:
    make menuconfig

    Если перейти в подменю “Security options”, первым пунктом мы увидим наш модуль, отмеченный символом “y” (мы установили это значение по умолчанию, когда создавали файл Kconfig), что означает, что мы интегрируем наш модуль непосредственно в код ядра.

Сборка

На этом этапе проводим самую обыкновенную сборку ядра, как это было описано в начале статьи. Но поскольку мы уже предварительно собрали чистое ядро, процесс немного упростился:

make && make modules

make не требует параметра -j, поскольку пересоберёт ядро с нашим модулем за несколько секунд.

sudo make install

Установка заголовков и модулей не требуется, это было произведено ранее.

Всё!

Осталось перезагрузить систему, после чего в ядре будет висеть наш модуль с перехватом mkdir. Как и говорил ранее, проверяем так:

dmesg | grep '<my_tag>'

Учтите, что в системе, скрываясь от ваших глаз, происходит очень много процессов, так что не удивляйтесь, когда увидите там много перехватов.

Надеюсь, кому-то это руководство будет полезным(если бы кто-то написал его вместо меня до того, как я начал копаться в ядре — он сэкономил бы мне 2-3 недели жизни).

Любая критика приветствуется.
Спасибо за внимание.

Автор: loskamo

Источник

Поделиться

* - обязательные к заполнению поля