Издательский дом ООО "Гейм Лэнд"ЖУРНАЛ ХАКЕР #98, ФЕВРАЛЬ 2007 г.

Призраки ядра, или модули-невидимки

Крис Касперски

Хакер, номер #098, стр. 110


Создание LKM-модулей, которые невозможно обнаружить

Потребность в создании невидимых модулей ядра растет с каждым днем — антивирусная индустрия набирает обороты, на рынке присутствует множество virginity-checker'ов, проверяющих систему на предмет дефлорации. Кроме того, в хакерских журналах опубликована масса статей, рассказывающих, как прятать модули от штатных средств ОС, в результате чего старые трюки уже не работают. Требуется что-то принципиально новое. В этой статье речь пойдет главным образом о сокрытии загружаемых модулей в Linux, но предложенные приемы с ничуть не меньшим успехом можно использовать в NT и BSD.

Лучший способ замаскировать модуль — не иметь модуля вообще. И это не шутка! Модули представляют собой унифицированный механизм, обеспечивающий легальную загрузку/выгрузку компонентов ядра. Однако существуют и другие механизмы проникновения на уровень ядра, некоторые из них описаны в моей статье «Захват нулевого кольца», но все они не универсальны и не надежны. С другой стороны, любая попытка явного стелсирования (смотри статью «Поиграем с туксом в прятки») — это стопроцентное палево, выдающее факт вторжения с головой. Антивирусу достаточно вручную пройтись по всем структурам ядра, а затем сравнить полученный результат с данными, возвращенными легальными средствами (например, командой lsmod).

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

Руководящая идея

Пишем модуль как обычно, но в процедуре init_module() выделяем блок памяти вызовом __get_free_pages (или любой другой функцией из семейства kmalloc(), смотри врезку «Чем выделять память»). Копируем туда резидентный код, делающий что-то «полезное», перехватываем все необходимые системные вызовы, заставляя их передавать управление резидентному коду (который, кстати говоря, должен быть перемещаемым, то есть сохранять работоспособность независимо от базового адреса загрузки). После этого мы возвращаем -1, сообщая системе, что вызов init_module() потерпел неудачу.

В результате — модуль не загружается, но и выделенная им память не освобождается, а это значит, что резидентный код продолжает работать! Причем определить, каким именно модулем был выделен тот или иной блок памяти, в общем случае невозможно, и даже обнаружив резидентный код, антивирус не сможет сказать, откуда он тут взялся!

Proof-of-concept module, или готовая демонстрация

Давай в качестве разминки соорудим минимально работающий невидимый LKM-модуль для Linux с ядром версии 2.4 (ядро 2.6 потребует незначительных изменений, о которых я расскажу ниже). Вот в операционных системах BSD и NT все сильно по-другому, хотя основополагающий принцип тот же: в процедуре инициализации выделяем память, копируем туда резидентный код, перехватываем один или несколько системных вызовов и возвращаем ошибку, приводящую к выгрузке модуля из памяти. Подробнее о технике написания LKM- и KLD-модулей под BSD можно прочитать в моих предыдущих статьях. Также рекомендуется ознакомиться с циклом статей Four-F'а на wasm'e, покрывающим собой все основные аспекты разработки драйверов: www.wasm.ru/article.php?article=drvw2k01.

Но вернемся к Linux'у. Наш «невидимка» будет перехватывать системный вызов SYS_mkdir, возвращая неизменную ошибку вместо передачи управления оригинальному syscall'у, в результате чего создание новых директорий окажется невозможным (во всяком случае, до перезагрузки системы). Это сделало для облегчения листинга и упрощения его понимания. Примеры реализации полноценных перехватчиков содержатся в моей статье «Системный шпионаж в *nix».

В качестве шасси мы будем использовать скелет LKM-драйвера, приведенный в уже упомянутой статье «Поиграем с туксом в прятки». Фактически мы только выбросим процедуру cleanup_module(), выполняющуюся при выгрузке модуля из памяти (ведь наш модуль никогда не выгружается), добавим функцию thunk_mkdir(), замещающую собой старый системный вызов SYS_mkdir(), и напишем несколько сток кода, обеспечивающих выделение памяти, копирование thunk_mkdir() и подмену оригинального SYS_mkdir'а. Если отбросить комментарии, на все про все понадобится менее десяти строк на языке Си!

Сердцевина невидимого LKM-модуля

// заглушка на функцию SYS_mkdir, всегда возвращающая -1,

// то есть блокирующая всякую попытку создания директории с

// сообщением об ошибке ;), естественно, в полновесном

// вирусе или rootkit'е здесь должен быть обработчик,

// передающий управление оригинальному системному вызову

thunk_mkdir()

{

return -1; // директория не создается ;-)

}

...

// EntryPoint: стартовая функция модуля, ответственная

// за его инициализацию и возвращающая 0 (при успешной

// инициализации) и -1 (если в ходе инициализации были

// зафиксированы неустранимые ошибки)

int init_module(void)

{

// выделяем одну страницу ядерной памяти

new_mkdir = (void *) __get_free_page(GFP_KERNEL);

// проверяем успешность выделения памяти

if (!new_mkdir) return -1 | printk("mem error!\n");

// определяем адрес оригинального вызова SYS_mkdir

// (в данной версии модуля никак не используется)

old_mkdir = sys_call_table[SYS_mkdir];

// копируем резидентный код нового SYS_mkdir в блок

// памяти, выделенный вызовом __get_free_page

memcpy(new_mkdir, thunk_mkdir, thunk_end - thunk_mkdir);

// модифицируем таблицу системных вызовов, заменяя

// старый вызов mkdir новой процедурой-заглушкой

sys_call_table[SYS_mkdir] = new_mkdir;

// выводим отладочное сообщение, что все ОК

printk("SYS_mkdir is now hooked!\n");

// возвращаем ошибку, предотвращая загрузку модуля,

// но оставляя резидентный код в памяти

return -1;

}

Для переноса модуля на ядро 2.6 прототип функции инициализации следует переписать так:

static int __init my_init()

module_init(my_init);

Пара замечаний. Перечень системных вызовов (вместе со способом передачи аргументов) лежит на docs.cs.up.ac.za/programming/asm/derick_tut/syscalls.html. В частности, SYS_mkdir принимает 2 аргумента: в EBX передается указатель на имя создаваемой директории, в ECX – флаги, описанные в mkdir(2). При желании, проанализировав *EBX, мы можем блокировать создание только определенных директорий, например, тех, что используют антивирусы и прочие защитные средства по умолчанию. Конечно, это демаскирует присутствие rootkit'а, но до некоторой степени затрудняет его удаление из системы.

Перехват syscall'ов осуществляется вполне стандартно и традиционно: ядро экспортирует переменную extern void sys_call_table, указывающую на таблицу системных вызовов, определения которых содержатся в файле /usr/include/sys/syscall.h. В частности, за mkdir закреплено «имя» SYS_mkdir.

Объявив в модуле переменную extern void *sys_call_table[], мы получим доступ ко всем системным вызовам, которые только есть (включая нереализованные). old_mkdir = sys_call_table[SYS_mkdir] заносит в переменную old_mkdir указатель на системный вызов SYS_mkdir, а sys_call_table[SYS_mkdir] = new_mkdir меняет его на new_mkdir, который должен располагаться в ядерной области памяти.

Внимание: если забыть скопировать new_mkdir в предварительно выделенный блок памяти, то после выгрузки модуля SYS_mkdir будет указывать на невыделенную область памяти и приложение, вызывавшее функцию mkdir, завершится с сигналом 11 – segmentation fault. Но ядро продолжит функционировать в нормальном режиме, и никаких голубых экранов, которыми так славится NT, тут не произойдет.

Необходимо отметить, что, на самом деле, ядро ничего не экспортирует (в привычной для NT-программистов трактовке этого слова). В каталоге /boot лежит файл System.map, содержащий символьную информацию обо всех «публичных» переменных и процедурах ядра. Его-то загрузчик модулей и использует. Если этого файла нет (например, удален администратором из соображений безопасности), определять адрес таблицы символов приходится эвристическим путем, но это уже тема отдельной статьи.

Сборка и загрузка

Компиляция модулей никакой сложности не представляет. Ключи, опции оптимизации и прочие специи — по вкусу. В общем случае командная строка должна выглядеть так:

# gcc –c module-hide.c –o mod-hidden.o -O2

Если компиляция невидимого LKM-модуля прошла без ошибок, то на диске образуется файл module-hide.o, готовый к загрузке внутрь ядра командой insmod (для автоматической загрузки модуля вместе с операционной системой необходимо добавить его в файл /etc/modules):

# insmod mod-hidden.o

Система тут же начнет ругаться на всех языках, которые только знает (точнее, на тех, под которые ее локализовали), типа модуль не загружен, неверные параметры, инвалидный IO или IRQ. Но не стоит волноваться. Все идет по плану! Это просто результат работы return -1 в init_module().

Главное то, что в списке загруженных модулей (выводимых командой lsmod или ее аналогом dd if=/proc/modules bs=1) наш модуль отсутствует, как будто бы мы никогда туда его не загружали. Однако команда mkdir дает ошибку, убеждая нас в том, что резидентный код успешно обустроился на конспиративной квартире и ведет активную борьбу!

# mkdir nezumi

mkdir: невозможно создать каталог 'nezumi': Operation not permitted

Резидентный код в камуфляжных штатах

Замаскироваться-то мы замаскировались, но подобное грубое вторжение в таблицу системных вызовов навряд ли сможет долго остаться незамеченным. Существует куча утилит, проверяющих целостность sys_call_table и автоматически восстанавливающих ее, отбирая у резидентного кода все бразды правления. Но даже без них указатель на системный вызов, расположенный вне ядра, вызывает слишком большие подозрения.

Чтобы не сгореть на первом же допросе, необходимо слегка изменить тактику: оставить в покое sys_call_table и внедрить jump на резидентный код в начало перехватываемого системного вызова. Впрочем, jump в начале системных вызовов — весьма популярный (а потому широко известный) способ перехвата, и опытные админы нас все равно запалят. Чтобы избежать расправы, необходимо внедряться не в начало, а в середину системных вызовов! А для этого надо тащить за собой целый дизассемблер, поскольку длина x86-инструкций непостоянна и варьируется в весьма широких пределах. Однако можно пойти на хитрость и искать плацдарм для внедрения эвристическим путем, например по сигнатуре 85h C0h * 7xh, соответствующей конструкции TEST EAX,EAX/Jx target. Звездочка означает, что между TEST EAX,EAX и Jx target может быть расположено несколько машинных команд. Во избежание ложных срабатываний не следует выбирать расстояние между 85h C0h и 7xh более четырех байт. Естественно, внедряя jmp near our_resident_code поверх TEST EAX,EAX..., необходимо предварительно сохранить затираемое содержание в своем собственном буфере и выполнить его перед передачей управления оригинальному системному вызову.

Важно отметить, что подобный способ перехвата не является на 100% надежным и безопасным, поскольку существует ничтожная вероятность, что выполнение процесса будет прервано в момент установки jump'а и тогда он рухнет. Но rootkit'ы об этом могут не беспокоиться, да и падения такие будут происходить не чаще, чем раз в сто лет.

Маскируемся в адресном пространстве

Вот теперь мы замаскировались так замаскировались! Только хвост все равно из норы торчит, и наш резидентный код может быть найден тривиальным сигнатурным поиском путем сканирования памяти ядра (естественно, при условии, что он известен антивирусам, а все популярные rootkit'ы — известны им). Чтобы остаться необнаруженным, необходимо использовать либо продвинутые полиморфные методики, либо один способ, о котором нельзя не рассказать.

Сбрасываем страницы, принадлежащие нашему резидентному коду, в no_access, вешаем обработчик исключений, отлавливающий ошибки доступа к памяти, и терпеливо ждем. Как только возникнет исключение, смотрим: если на вершине стека находится адрес возврата в системный вызов (для этого перехват должен осуществляться командой CALL, а не jump), то возвращаем все атрибуты на место и даем зеленый свет на выполнение резидентного кода, а в момент передачи управления оригинальному системному вызову — отбираем атрибуты обратно. Если же резидентный код пытается читать кто-то еще — подсовываем другую страницу (например, путем манипуляций с каталогом страниц). Более сложные реализации не восстанавливают атрибуты, а используют пошаговую трассировку резидентного кода или даже эмулируют его выполнение, но это уже перебор.

Все просто, только вот мыщъха терзают смутные сомнения на счет эффективности. Но ведь не он же этот трюк придумал! Так что может и покритиковать. Первое и самое главное. Читать резидентный код в памяти ядра могут не только антивирусы, но и само ядро при вытеснении его на диск или переходе в спящий режим. Как следствие — возникает конфликт, и rootkit работает нестабильно. Второе — код обработчика остается незащищенным (а защитить его никак нельзя, поскольку кто-то же должен обрабатывать исключения), следовательно, он элементарно палится по сигнатурному поиску. Как говорится, за что боролись, на то и напоролись. Так что без полиморфизма никуда!

Чем выделять память

Для выделения памяти ядро предоставляет богатый ассортимент функций, описанных в kmalloc(9). В первую очередь хотелось бы отметить функцию void *kmalloc(size_t size, int priority), где size - размер запрашиваемого блока в байтах (принимает одно из следующих значений: 24, 56, 120, 244, 500, 1012, 2032, 4072, 8168, 16360, 32744, 65512 или 131048). В противном случае функция автоматически округлит размер блока в большую сторону.

Параметр priority задает стратегию выделения памяти. GFP_ATOMIC выделяет требуемую память немедленно (при необходимости вытесняя другие страницы на диск); GFP_KERNEL резервирует блок памяти, выделяя страницы памяти по мере обращения к ним; GFP_BUFFER никогда не вытесняет другие страницы, и если запрошенная память недоступна, с выделением наступает облом. Существуют и другие стратегии выделения, но нам они не интересны, поскольку фактически приходится выбирать между GFP_ATOMIC и GFP_KERNEL. Обычно используют GFP_KERNEL, так как он ведет себя не столь агрессивно.

Если нужно выделить всего одну страницу памяти, имеет смысл воспользоваться функцией unsigned long __get_free_page(int priority), где priority тот же самый, что и у kmalloc(). Ее ближайшая родственница get_free_page(int priority) отличается только тем, что обнуляет память сразу же после выделения, что несколько снижает производительность. И к тому же мы все равно будем копировать резидентный код через memcpy(), так что содержимое страницы нам не критично.

Определения всех функций (с краткими комментариями) содержатся в заголовочном файле linux/mm.h.

Грабеж отладочного вывода

Функция printk(), используемая нами, позволяет генерировать отладочный вывод, который не появляется на экране, чтобы не смущать пользователей обилием технической информации, в которой они все равно ни разу не разбираются. Что ж, вполне логично, что отладочный вывод должен быть доступен только разработчикам, но как же до него добраться? NT имеет «Системный Журнал» (и притом не один), но в Linux'е ничего похожего нет, и отладочный вывод бесхитростно валится в текстовый файл /proc/kmsg, который можно прочитать утилитой cat:

# cat /proc/kmsg > filename

Лучше использовать специализированные средства, вроде штатной утилиты dmesg или иксовой гляделки:

# xconsole -file /proc/kmsg

CD

На прилагаемом к журналу диске ты найдешь полную версию файла module-hide.c и все статьи, на которые ссылается мыщъх.

INFO

Какие именно системные вызовы перехватывать и как осуществлять фильтрацию, можно прочитать в любой статье, посвященной технологии создания rootkit'ов, например: «Abuse of the Linux Kernel for Fun and Profit» (Phrack #50), «Weakening the Linux Kernel» (Phrack #52), «Sub proc_root Quando Sumus» (Phrack #58), «Kernel Rootkit Experiences» (Phraсk #61) и т.д.

Содержание