Доклады, выступления, видео и электронные публикации

Особенности доступа к системным функциям ядра ОС GNU/Linux

B. П. Лось, д-р техн. наук
Московский государственный индустриальный университет (МГИУ), институт криптографии, связи и информатики Академии ФСБ России, Москва

Ядро Linux на сегодняшний день, пожалуй, яв­ляется единственной унифицированной и единой составляющей каждого дистрибутива Linux. Большинству крупных вендоров (RedHat, Novell, Canonical и т. д.) доступно внесение определенных изменений в исходные коды ядра, но такие изме­нения либо не должны конфликтовать с эталон­ным кодом (так называемым «ванильным» ядром), либо будут в дальнейшем внесены в основную вет­ку ядра, таким образом ядро едино для всех.

Учитывая данную особенность, для ОС семей­ства Linux существует возможность достаточно просто (за исключением некоторых вопросов со­вместимости с предыдущими версиями ядра) раз­рабатывать различные расширения — модули яд­ра, которые фактически являются частью ядра и могут переопределять/дополнять различные его функции, т. е. изменять порядок работы ядра ОС Linux. Причем такие модули, с некоторыми оговорками, будут совместимы с большинством дистрибутивов Linux без внесения в их код каких-либо изменений.

Таким образом, ядро Linux является монолит­ным (т. е. содержит в себе достаточный функцио­нал для нормального функционирования системы без прочих дополнений/расширений), но при этом поддерживает загружаемые модули ядра (LKM — Linux Kernel Modules или Loadable Kernel Modules), которые выполняются в 0-м кольце защиты, с полным доступом к оборудова­нию, причем загрузка/выгрузка таких модулей может осуществляться во время работы системы (ядра ОС) без перезагрузки.

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

  • все модули ядра могут загружаться / вы­гружаться в пространство ядра Linux только с правами суперпользователя (root);
  • в самом ядре существуют специальные меха­низмы, предотвращающие выгрузку критич­ных модулей ядра в момент их работы (по умол­чанию ядро собирается с опцией MODULE_FORCE_UNLOAD=0, т. е. без воз­можности принудительной выгрузки модулей ядра с параметром -force — 'rmmod -force module.ко');
  • самим модулям в явном виде не разрешено совершать действия, которые могут влиять на ра­ботающую систему (например, изменять данные структур запущенных процессов, осуществлять доступ к памяти ядра и т. п.). Для таких действий требуются предварительные манипуляции, однако потенциально эти действия возможны и не запре­щены при отключении блокировок типа GFP (General Fault Protection).

В связи с этим можно утверждать, что загру­жаемые модули ядра не могут сами по себе яв­ляться средством повышения привилегий в системе и/или быть уязвимостью системы. Целесообраз­ность использования загружаемых модулей ядра с точки зрения злоумышленника заключается в со­крытии собственного присутствия в системе и не более, т. е. в действиях непосредственно после взлома отдельно взятой системы.

Чем могут быть полезны загружаемые модули ядра ОС Linux в плане повышения безопасности системы и / или разработки «навесных» средств защиты? Такие модули можно использовать для перехвата системных функций ядра с целью орга­низации своей, внешней по отношению к ОС, под­системы разграничения доступа в ОС.

Интерфейс системных вызовов ядра ОС Linux

Интерфейс системных вызовов (system call interface) является прослойкой в ядре ОС, исполь­зуя которую прикладное ПО (из пользовательско­го режима) может получать доступ к оборудова­нию, работать с файловыми системами и т. п. Таким образом, фактически любое действие в сис­теме требует вызова того или иного системного вызова (будь то запись/чтение файла, изменение прав доступа, запуск нового процесса или любые действия с выделением/освобождением памяти), а общую схему работы с системными вызовами можно схематично представить на рис. 1.

Рис. 1. Использование системных функций ядра ОС Linux пользовательскими приложениями

Итак, любое приложение, используя функции стандартной библиотеки (glibc), неявно для себя вызывает определенные системные вызовы (system_call), специальная процедура ядра ОС про­сматривает таблицу системных вызовов (sys_call_table), находит адрес нужной функции ядра, вызывает эту функцию и передает управление обратно приложению только после выполнения этого системного вызова (при этом ядро также осуществ­ляет разграничение доступа разных приложений к одним и тем же системным вызовам по времени).

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

Что требуется для внедрения собственных ме­ханизмов безопасности в ОС Linux? Фактически требуется написать собственные реализации основ­ных системных вызовов, которые в зависимости от успешности/неуспешности определенных прове­рок будут вызывать/не вызывать выполнение эта­лонных системных вызовов. Таким образом стано­вится возможным реализовать собственные дис­креционные и мандатные механизмы разграниче­ния доступа (или любые другие), которые будут работать непосредственно до отработки всех штат­ных подсистем разграничения доступа ОС. Сами функции, «переопределяющие» работу системных вызовов логично описать в загружаемом модуле яд­ра, а при инициализации этого модуля необходимо заменять адреса системных вызовов в таблице сис­темных вызовов на адреса переопределяющих их функций (при этом желательно при выгрузке моду­ля совершать обратное действие) (рис. 2).

Рис. 2. Перехват системного вызова с помощью модуля ядра Linux

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

Предположим, у нас есть 2 загружаемых моду­ля ядра ОС Linux module_A и module_B и каж­дый из них изменяет адрес одного и того же сис­темного вызова 'open' на адрес своей функции (ореп_А и ореп_В, соответственно). Первым за­гружается module_A и заменяет адрес в sys_call_table[_NR_open] на адрес своей функ­ции ореп_А. Затем загружается module_B и заме­няет адрес ореп_А адресом своей функции ореп_В (module_B при этом подозревает, что подменил оригинальный системный вызов).

Теперь если module_B выгрузится первым — в системе ничего плохого не произойдет — при вы­грузке module_A в таблице системных вызовов будет восстановлен оригинальный вызов 'open'. Однако, если же первым выгрузится module_A, будет восстановлен оригинальный системный вы­зов 'open', а при выгрузке module_B системный вызов 'open' будет заменен на ореп_А (которой вообще говоря уже нет в памяти).

На первый взгляд проблему можно решить, за­меняя адрес обратно только в случае, если адрес в таблице системных вызовов совпадает с адресом функции модуля (ореп_А или ореп_В), но в таком случае при выгрузке в качестве первого модуля module_A — адрес sys_call_table переписан не будет (т. е. не вернется оригинальный системный вызов ’open’), таким образом данный подход так­же не решает проблему.

Для предотвращения потенциальной опасности, связанной с изменением адресов системных вызо­вов, начиная с версии ядра ОС Linux >= 2.5.41, более не экспортируется адрес таблицы системных вызовов (sys_call_table). Таким образом, физиче­ски найти и подменить адрес нужного нам систем­ного вызова стало сложнее.

Использование механизма LSM для перехвата системных вызовов

Однако в ядрах версии 2.6 для перехвата сис­темных вызовов появился новый механизм, с по­мощью которого знание адреса таблицы системных вызовов стало вообще ненужным. Данный меха­низм имеет название LSM (Linux Security Modules)[1] и позволяет перехватывать системные вызовы, вставляя свои обработчики системных вызовов, без необходимости самому подменять ад­реса системных вызовов и /или вникать в струк­туру и последовательность использования функ­ций и ресурсов ядра, т. е. грубо говоря LSM — набор предустановленных в ядре ОС хуков (от англ. hook), которые предоставляют API для вне­дрения собственных обработчиков непосредственно перед выполнением определенного системного вы­зова.

Рассмотрим пример LSM-модуля ядра, который будет подменять стандартный системный вызов mkdir своим обработчиком, который дополнитель­но к созданию каталога будет выводить в dmesg некоторое сообщение, каждый раз когда вызывает­ся команда mkdir:

1. Для того чтобы модуль ядра начал использовать механизм LSM, в его функции инициализации и деинициализации (module_init и module_exit) необходимо добавить вызов сле­дующих 2 функций, соответственно:

/* Регистрация перехватчиков системных вызовов
* @return 0 в случае успешной регистрации, иначе - код ошибки*/

int hook_register() {

int res = register_security(&hook_security_ops);

if (res) {
printk(KERN_ERR , res); return res;
}
return 0;
}

/* Дерегистрация перехватчиков системных вызовов */

void hook_unregister() {

int res = unregister_security(&hook_security_ops);
if (res) printk(KERN_ERR , res);

}

2. Как видно из примера выше LSM-модуль ядра регистрирует собственные обработчики с помощью функций register_security() (дерегистрирует с помощью unregister_security(), обе функции объявлены в ), передавая в эту функцию структуру вида:

/* Операции, перехватывающие системные вызовы*/

static struct security operations hooksecurityops = { .inodernkdir = inodernkdir,};

3. В данном случае в структуре типа security_operations перечислены системные вызовы вместе с функциями (нашими хуками), которые выполняются непосредственно перед выполнением указанных системных вызовов (в нашем случае перед системным вызовом inode_mkdir выпол­няется наша функция inode_mkdir, которая должна быть объявлена в этом же модуле ядра).

4. Сама функция, вызываемая до выполнения системных вызовов может быть, например, такой:

/* Перехват запроса на создание каталога*/

static int inode_mkdir(struct inode *dir, struct dentry *dentry, int mode)

{

printk (); return 0;

}

5. В данном случае, загрузив модуль в ядро Linux и выполнив команду mkdir, в выводе dmesg можно будет увидеть новую строчку с фразой «mkdir hijacked!», свидетельствующую о том, что LSM-модуль успешно перехватывает системный вызов inode_mkdir.

Одной особенностью использования механизма LSM является невозможность одновременного ис­пользования нескольких модулей ядра для регист­рации хуков — при попытке регистрации LSM-модуля ядра будет выведено соответствующее пре­дупреждение, таким образом при использовании стандартного вкомпилированного в большинство версий ядра SELinux использовать собственный модуль безопасности LSM не получится (отключе­ние SELinux с помощью параметра ядра в загруз­чике типа selinux=0 также может не принести ни­какого результата). Поэтому для свободного использования собственного LSM-модуля ядра приходится перекомпилировать ядро Linux, ис­ключая из него любые дополнительные средства защиты (такие, как SELinux, AppArmor, Tomoyo и прочие).

Несмотря на это неудобство механизм LSM ре­шает очень важную задачу — он предоставляет API, который защищает систему от возможной порчи таблицы системных вызовов в случае после­довательного изменения адресов одних и тех же системных вызовов двумя и более модулями ядра, а также в случаях наличия ошибок в модуле ядра, производящем такие изменения (или например если модуль ядра при выгрузке не заменяет адреса системного вызова обратно).

Так или иначе, в целях безопасности начиная с версии ядра Linux 2.6.24 и выше механизм LSM (а именно функция, позволяющая использовать данный механизм) перестал экспортироваться ядром ОС Linux (данное обстоятельство связано с тем, что большинство вредоносного ПО скрывало свое присутствие в системе именно с помощью ме­ханизма LSM).

Перехват системных вызовов в загружаемых модулях ядра Linux (LKM)

Возвращаясь к обычному перехвату системных вызовов (и отбрасывая проблему возможной порчи таблицы системных вызовов, т. е. фактически не имея других вариантов для корректного перехвата системных вызовов) предстоит дополнительно ре­шить сразу 2 задачи:

  • найти адрес таблицы системных вызовов в памяти ядра Linux (так как с версии 2.5.41 этот адрес перестал экспортироваться, а в ядрах 2.6.* память ядра защищена от записи), а если подробнее, то необходимо:
    • найти адрес sys_call_table;
    • получить возможность изменения sys_call_table;
  • корректно заменить нужные нам системные вызовы.

Задачу поиска таблицы системных вызовов можно решить сразу несколькими способами:

  • найти адрес sys_call_table в файле /boot/System.map, содержащем все используемые ядром символы и их адреса в памяти ядра (такой файл создается при каждой компиляции ядра), например так — grep sys_call_table/ /boot/System.map | awk ’{print \$$1}’* и передать в модуль ядра через Makefile. Таким образом, данный метод фактически не позволяет распространять модуль ядра в собранном виде (в ви­де бинарного файла с расширением .ко) — его необ­ходимо будет компилировать на каждой новой ОС;
  • найти адрес sys_call_table в файле /boot/System.map с помощью функции модуля ядра — в данном случае пересобирать модуль под конкретную систему не обязательно;
  • найти адрес sys_call_table в памяти ядра ОС с помощью разбора адресов соседних структур (например «loops_per_jiffy» и «cpu_boot_data»). В данном случае необходимо учитывать, что не во всех версиях ядра ОС таблица системных вызовов будет располагаться именно между указанными структурами;
  • найти адрес sys_call_table в памяти ядра ОС, для примера это можно сделать обычным перебором со сравнением определенного элемента таблицы системных вызовов с тем значением, которое должно быть в качестве этого элемента, например так:

//значения для 32-разрядных ядер ОС Linux
#define START_MEM 0xc0000000
#define END_MEM 0xd0000000
unsigned long *syscall_table;
unsigned long **find_syscall_table() {
unsigned long **sctable;
unsigned long int i = START_MEM;

while (i < END_MEM) {
sctable = (unsigned long **)i;

if (sctable[_NR_close]== (unsigned long *) sys close)

return &sctable[0]; i += sizeof(void *);

}

return NULL;

}

//найти таблицу системных вызовов можно следующим образом syscalltable = (unsigned long *) find_syscall_table();

Возможности изменения sys_call_table в ядрах 2.6.* и выше по умолчанию не существует. Связа­но это с тем, что ядро помещает sys_call_table в специальную область памяти «read-only», защищая таким образом ее от намеренного или непреднаме­ренного изменения (так как это может вести к не­корректной работе системы, о чем было сказано выше). В связи с этим необходимо временно (на время внесения изменений в адреса таблицы sys_call_table) переключать режим доступа к таб­лице системных вызовов, например следующим способом:

/* отключить защищенный режим, установив бит WP в 0 */ write_cr0 (read_cr0 () & (~ 0?10000));

/* выполнить изменения таблицы системных вызовов */

/*включитъ запгдщенньгй режим, установив бит WP в 1 */ write_cr0 (read_cr0 () | 0?10000);

В данном случае блокировка связана с архитек­турой используемого процессора, в качестве при­мера рассматривается блокировка Intel, при кото­рой 0-й бит CR (управляющего регистра, Control Register) необходимо переключать в 0 для отключения «protected mode», а затем в 1 для включения «protected mode» уже после изменения таблицы системных вызовов. Также существуют другие блокировки, зависящие от архитектуры, на которой используется ОС.

Механизм замены системных вызовов собствен­ными функциями можно организовать следующи­ми способами:

  • обычной подменой адреса системного вызова адресом своей функции, при этом таблицу систем­ных вызовов необходимо возвращать в исходное состояние после отработки модуля ядра:

/* переменная для сохранения адреса оригинальной функции */
asmlinkage int (* origmkdir) (struct inode *dir, struct dentry *dentry, int mode);

/* новая функция, заменяющая стандартный системный вызов */
asmlinkage int newrnkdir (struct inode *dir, struct dentry *dentry, int mode) {

printk (<\nmkdir hijacked!\n>);
return orig mkdir (dir, dentry, mode);

}

/* функция изменения таблицы системных вызовов */
static void patch_sys_call_table() {

#if LINUX VERSION CODE >= KERNEL_VERSION(2, 5,0)
writecr0 (readcr0 () & (~0?10000));
#endif

origmkdir = sys_call_table[_NRrnkdir];

sys_call_table[_NRrnkdir] = newrnkdir;

#if LINUX VERSION CODE >= KERNEL_VERSION(2, 5, 0)
write cr0 (readcr0 () | 0?10000);
#endif }

/* функция возврата таблицы системных вызовов в начальное состояние */
static void revert_sys_call_table() {

#if LINUX VERSION CODE >= KERNEL_VERSION(2, 5, 0)
write cr0 (read cr0 () & (~0?10000));
#endif

sys_call_table[_NRrnkdir] = origmkdir;

#if LINUX VERSION CODE >= KERNEL_VERSION(2, 5, 0)
write cr0 (read cr0 () | 0?10000);
#endif }

/* функция инициализации модуля ядра */
static int init(void) {

/* применяем патч к таблице системных вызовов */

patch_sys_call_table(); printk("sys_call_table patched.\n");
return 0;

}

/* функция окончания работы модуля ядра */
static void exit(void) {

/* возвращаем состояние таблицы системных вызовов */
revert_sys_call_table(); printk("sys_call_table reverted.\n<); return;

}

  • используя механизм LSM, который более не экспортируется, но позволяет:
    1. «обезопаситься» от замены системных вызо­вов двумя разными модулями;
    2. приложить меньше усилий для изменения различных структур ядра ОС, так как сам меха­низм LSM часть блокировок отключает в своих хуках (таким образом функции обработчиков бу­дут содержать меньше лишнего кода).

Выводы

Создание собственной подсистемы разграниче­ния доступа в ОС семейства Linux в техническом плане не является чем-то сложным или недоступ­ным. Получить контроль над системными функ­циями ядра (системными вызовами), как было показано в данной статье, достаточно просто — поэтому основной задачей является разработка тех проверочных правил, которые должны вызываться при выполнении того или иного системного вызова ядра ОС.


[1] Chris Wright, Crispin Cowan, Stephen Smalley, James Morris, Greg Kroah-Hartman. Linux Security Modules: General

Авторы: Каннер А. М.; Лось В. П.

Дата публикации: 01.01.2012

Библиографическая ссылка: Каннер А. М., Лось В. П. Особенности доступа к системным функциям ядра ОС GNU/Linux // Вопросы защиты информации. Научно-практический журнал. М., 2012. №3. С. 39–44.


Scientia potestas est
Кнопка связи