Защита от исполнения в стеке (ОС Линукс).

Защита от исполнения в стеке (ОС Линукс).

В данной статье речь пойдет о разработке модуля безопасности (загрузочного модуля ядра), который будет отслеживать выполнение некоторых системных вызовов в контексте стека процесса. Разрабатывалось и тестировалось все на ядре 2.6.8.1 для I386, хотя, возможно, данное решение подойдет для любого ядра ОС Линукс, естественно, i386.

Автор dev0id mail.ru

Предисловие.

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

  • Необходима достаточная квалификация – знание устройства ОС.
  • Необходимы средства для разработки и внедрения продукта.
  • Необходимо обеспечение приемлемой ресурсопотребляемости.

Говоря об операционной системе Линукс, остаются только первый и последний пункт, так как средства для разработки бесплатного ПО, к сожалению, выделять никто не будет. В данной статье речь пойдет о разработке модуля безопасности (загрузочного модуля ядра), который будет отслеживать выполнение некоторых системных вызовов в контексте стека процесса. Разрабатывалось и тестировалось все на ядре 2.6.8.1 для I386, хотя, возможно, данное решение подойдет для любого ядра ОС Линукс, естественно, i386.

Введение.

Задача защиты стека от исполнения перед разработчиками появилась, если не в тот же, то на следующий день после того, как хакеры научились исполнять в нем код. Нельзя сказать, что данная задача решена полностью. Обратите внимание на объем затрачиваемых ресурсов после того, как вы установите необходимый патч ядра. К слову говоря, сам факт того, что это патч ядра, уже наталкивает на мысли о том, что придется провести несколько лишних минут, а возможно и часов, за собиранием нового ядра. Основным алгоритмом используемых методов защиты стека от исполнения команд в таких патчах является своеобразная отладка всех процессов в системе, при которой проверяется текущее состояние регистра eip. Если углубиться в рассмотрение алгоритма, то выяснится, что для такой отладки необходимы постоянные исключения и их обработка при обращении к стеку, что существенно замедляет работу системы, так как любая программа что-то хранит в стеке, то есть часто обращается к нему. Плюсом такой реализации является 100% защита от исполнения любого кода в стеке процесса, что позволяет существенно повысить безопасность ОС. Что же касается минусов, то это, несомненно, скорость работы всей системы или отдельных ее компонент, в том числе запущенных процессов. В данном материале предлагается другой метод обнаружения попытки исполнения в стеке пользовательского процесса. Отмечу, что это решение новаторское и других подобных реализаций, встречено автором не было.

Теория.

Предположительно, 90% эксплоитов используют в своей работе шеллкод. В основном, данный код попадает в стек и там исполняется при определенных условиях. Думаю, стоит напомнить, что шеллкод – уже откомпилированный код, написанный определенным образом на ассемблере. В своей работе шеллкод использует системные вызовы – самые низкоуровневые команды ядра ОС, доступные пользователю. Обратиться к системным вызовам BIOS пользователю не удастся, так как само ядро предполагает монопольное использование этих вызовов. Таким образом, даже суперпользователь root не способен обойтись без использования системных вызовов. Идеей алгоритма модуля безопасности является перехват системных вызовов ОС Линукс, в контексте которых будет проверяться и анализироваться состояние некоторых регистров до входа в системный вызов, то есть до того, как произошла смена стека с пользовательского на ядерный. Плюсом данного подхода является то, что проверка происходит строго в момент системного вызова, а именно, в нашем обработчике, то есть до вызова старого обработчика. Проверка состояния регистров осуществляется очень быстро и не нагружает систему бессмысленными действиями. Однако при реализации был встречен подводный камень, которого я никак не ожидал от разработчиков ядра Линукс: дело в том, что стандартный метод перехвата системных вызовов, описанный в документации с www.thc.org, не подходит по причине неэкспортируемости таблицы системных вызовов. Я пробовал отыскать руткиты, которые бы перехватывали системные вызовы под данным ядром, но не нашел ни одного. В голову пришли спонтанные решения:

  1. Пропатчить ядро таким образом: в /usr/src/linux/arch/i386/kernel/entry.S после описания таблицы sys_call_table внести макрос экспорта EXPORT_SYMBOL(sys_call_entry);
  2. Использовать метод описанный во Phrack#58 (Linux on-the-fly kernel patching without LKM )

Первый метод не совсем хорош по причине необходимости перекомпиляции ядра. Ко всему прочему, таблицей системных вызовов сможет пользоваться любой модуль. Второй метод слишком зависит от текущей платформы и алгоритмически сложен. Для реализации этого метода придется не раз организовывать поиск необходимых адресов и использовать сигнатурный поиск бинарных данных, что само по себе наводит на мысль о том, будет ли это работать на другой машине или другом ядре. При написании своего модуля я использовал другой подход. Идея заключается в следующем: Так как при рассмотрении файла System.map (в этом файле описываются имена переменных и функций ядра, с указанием того, экспортируются они или нет), выяснилось, что есть только один экспортированный системный вызов – sys_close. Задачей было найти его адрес в области данных ядра с целью выявления таблицы системных вызовов. Напомню, что сама таблица системных вызовов содержит адреса системных вызовов, что позволяет легко устанавливать свои обработчики, заменив адрес вызова из этой таблицы своим.

Реализация.

Первоочередная задача – заменить существующий обработчик любого системного вызова своим, то есть, поменять указатель в таблице системных вызовов интересующей нас функции на адрес новой, которую мы разрабатываем. Для начала взглянем на файл System.map. Обычно этот файл лежит в директории /boot/, но стоит заметить, что для каждого ядра свой файл System.map, так как при внесении незначительных, с точки зрения пользователя, изменений в конфигурации ядра и последующей его сборке, адреса некоторых функций и переменных меняются. Именно по этой причине следует использовать файл именно своего, текущего, ядра. К примеру, я использовал /usr/src/linux/System.map В нем сказано, что все системные вызовы экспортированы. Не стоит сразу этому верить. Обратимся к файлу /proc/kallsyms:

 
[dev0id@ustcomp boot]$ cat /proc/kallsyms |grep "sys_call_table"
[dev0id@ustcomp boot]$ cat /proc/kallsyms |grep "T sys_"
c0149ead T sys_close
[dev0id@ustcomp boot]$ 
В этом файле укзаны те же данные ядра, что и в файле system.map, при условии текущей конфигурации (к примеру, загруженных модулей), в то время, как файл System.map генерируется при компиляции ядра на основании исходников.

Все системные вызовы начинаются с префикса “sys_”. В данном случае, мы получили данные от ядра: есть всего один экспортированный системный вызов sys_close. Таблица системных вызовов в текущем ядре не экспортируется. Мы знаем адрес. Следует проверить это опытным путем: В модуле ядра, в секции инициализации пишем:

 
int
init_module()
{
    extern int sys_close(int fd);
    unsigned long *Addr=sys_close;
   
    printk(KERN_INFO“sys_close address: %x\n”,(unsigned long*) Addr);
return -1;
}
Для компиляции модуля понадобится Makefile, в котором должно быть следующее:
obj-m := md.o
при условии, что текст программы находится в файле md.c. Далее, находясь в директории с исходным текстом модуля и Makefile'ом, выполняем следующее:
[root@ustcomp stack]# make -C /usr/src/linux M=$PWD modules; insmod md.ko
на экране появится надпись, гласящая о том, что модуль не загружен (это из-за того, что функция инициализации модуля возвращает -1) и наша строчка с адресом функции sys_close.

На данный момент мы точно знаем, что искать – адрес функции sys_close, знаем где искать – в области данных ядра. Вопрос: “А где эта область данных находится?” Существует структура struct_task в которой описан текущий процесс, то есть процесс, который обратился к ядру за системным вызовом. Забегая вперед, скажу, что обратиться к нему можно через указатель current. В данной структуре присутствует указатель на другую структуру, описывающую менеджмент памяти данного процесса. Название данного указателя struct mm_struct *mm. В описании данной структуры встречается следующая строчка: unsigned long start_code, end_code, start_data, end_data;

Данная строчка описывает адреса начала секции данных и кода, а так же адреса, где они заканчиваются. Используя данную структуру, мы можем точно определить отрезок памяти, где нужно произвести поиск адреса системного вызова sys_close. Но не стоит торопиться: в данном случае, мы будем искать в адресном пространстве пользователя, в то время как таблица системных вызовов находится в адресном пространстве ядра. Следовательно, нам необходимо найти структуру, описывающую менеджмент памяти ядра. В файле arch/i386/kernel/init_task.c экспортируется переменная init_mm, которая является структурой mm_struct. Основной задачей данной переменной является описание менеджмента памяти для процесса инициализации ядра – процесса init (не нужно путать с процессом init у которого идентификатор процесса равен 1). Используя данную структуру в своем модуле, попробуем вывести на экран необходимые нам для поиска адреса. Для этого в функцию инициализации модуля добавляем слудющие строчки:

        printk("...Starting...\n");
        printk("start_code: %x\n",(unsigned long)init_mm.start_code);
        printk("end_code: %x\n",(unsigned long)init_mm.end_code);
        printk("start_data: %x\n",(unsigned long)init_mm.start_data);
        printk("end_data:   %x\n",(unsigned long)init_mm.end_data);
        printk("sys_close addr: %x\n",(unsigned long *)Addr);
        printk("...Finishing...\n");
        
Компиляция выполняется той же командой, что и в предыдущий раз. На экране при загрузке модуля выведутся необходимые нам адреса, за исключением начала данных. По какой причине это происходит, я объяснить не могу, но это не должно особенно нас тревожить вот по какой причине: Исследуя файл System.map, можно убедиться в том, что адрес, по которому расположена таблица sys_call_table, находится в промежутке между концом секции кода и концом секции данных. Оба эти адреса у нас есть(init_mm.end_code и init_mm.end_data), так что все, что нам нужно – пробежаться в этом промежутке в поисках необходимого нам значения – адреса функции sys_close. Вот, как это реализовано у меня:
unsigned long *ptr;
unsigned long arr[4];
int i;
ptr=(unsigned long *)((init_mm.end_code + 4) & 0xfffffffc);

        while((unsigned long)ptr < (unsigned long)init_mm.end_data)
        {
                if (*ptr == (unsigned long *)sys_close)
                {
                       for(i=0;i<4;i++)
                        {
                                arr[i]=*(ptr+i);
                                arr[i]=(arr[i] >> 16) & 0x0000ffff;
                        }
                        if(arr[0] != arr[2] || arr[1] != arr[3])
                        {
                                sys_call_table=(ptr - __NR_close);
                                break;
                        }
                }
                ptr++   ;
        }
        printk(KERN_INFO"sys_call_table base found at: %x\n",sys_call_table);
Проводя анализ полученных данных в своей системе, я пришел к выводу, что необходимый адрес находится в нескольких местах. Это заставило меня принять меры по эвристическому анализу полученных данных: Дело в том, что предположительно, адрес функции sys_close найден именно в таблице системных вызовов, значит соседние данные в этой таблице, такие же адреса системных вызовов. Если это системные вызовы, то они должны находиться в области кода, то есть найденные адреса обязаны указывать в секцию кода. Во-вторых, адреса полученных функций не должны совпадать. Проведя анализ, я так же обнаружил, что, выбрав следующие три адреса из таблицы и сравнив их, некоторые адреса совпадают, но это лишь говорит о том, что расстояние между началом следующей и концом предыдущей функции не очень большое. Вывод я сделал следующий: из данных функций есть хотя бы две, старшие 2 байта адреса которых не совпадают. Другими словами: есть хотя бы две функции, находящиеся на таком расстоянии друг от друга, что 2 старших байта их адресов различаются. Действительно, можно утверждать, что данная методика эвристического анализа данных, полученных из определенной области, не доработана. Но так же следует заметить, что на примере ядра 2.6.8 все работало. Возможно, для других ядер придется этот механизм дорабатывать. Забыл добавить: для того, чтоб данный пример заработал, необходимо описать указатель типа unsigned long и именем sys_call_table в качестве глобальной переменной. Далее, для того, чтоб перехватить системный вызов, необходимо делать все точно так же, как это было описано на www.thc.org. Проблему с перехватом системных вызовов в ядре 2.6 мы решили. Переходим к решению следующей задачи – обеспечение защиты от выполнения в стеке. Как говорилось ранее, любой шеллкод использует в своей работе системные вызовы ОС. Таким образом, анализируя регистры eip и esp процесса, обратившегося к ядру за системным вызовом, мы всегда можем определить, где происходит выполнение системного вызова: в сегменте кода, или в стеке. Кстати говоря, развивая эту тему, можно включить проверку сегмента данных, тем самым защитить себя от заражения файловыми вирусами, которые помещают свой код в сегмент данных. Я долго бился над тем, как получить регистры процесса на момент вхождения в контекст ядра. Разбираясь с ядром, я заметил следующую конструкцию:
struct pt_regs *regss;
regss=((struct pt_regs*)(THREAD_SIZE + (unsigned long)current->thread_info)) -1;
Данная конструкция получает данные о регистрах в специальную структуру pt_regs.

Параллельно с этим было найдено другое решение: проводя анализ исходного кода ядра было замечено, что некоторые системные вызовы, например sys_fork(), в качестве параметров принимают регистры, а точнее, ту же структуру pt_regs. Обращаясь к ней, возможно, выделить интересующие нас регистры. Для того, чтобы получить состояние регистров в любой версии ядра, следует использовать конструкцию: struct pt_regs *regss=(current->thread.esp0 - sizeof(struct pt_regs)); Можно утверждать, что данная конструкция будет работать почти в любой версии ядер(я не учитываю версии < 2.2 ). EIP регистр структуры struct pt_regs указывает на следующую после int $0x80 инструкцию в пользовательском процессе. Собрав воедино все вышеизложенное, я написал загрузочный модуль, который отслеживает исполнение системного вызова execve в контексте пользовательского стека - Код модуля

Данная программа обрабатывает системный вызов sys_execve(). При вызове этой системной функции на экран выводится состояние регистров. Для того, чтобы от этого модуля была польза, а именно, проверка того, где мы выполняем системный вызов, необходимо включить проверку старшего байта адреса на который указывает eip. К примеру, это можно сделать следующим образом, заменив обработчик our_sys_execve() на:

{
struct pt_regs *regss =(current->thread.esp0 - sizeof(struct pt_regs));
 unsigned long eip = regss->eip;
 unsigned long esp = regss->esp;
 esp = (esp >> 24) & 0x00ffffff;
 eip = (eip >> 24) & 0x00ffffff;
if(esp == eip)
{
   printk(KERN_INFO”ALERT!!! execution from the stack\n”);
   return -1;
} else
     return my_execve(filename,(const char**)argv,(const char**)envp);
}
Тут следует остановиться. И подумать о самозащите. Дело в том, что все параметры перед системным вызовом помещаются в регистры. При этом стек не используется, как, например, в BSD системах. Таким образом, данный модуль очень легко обмануть, если перед первым системным вызовом создать свой стек, существенно заменив значение esp. Для самозащиты мы используем уже знакомую нам структуру struct_mm, указатель на которую присутствует в структуре current. Дело в том, что в struct_mm есть поле, описывающее начальный адрес стека. Таким образом, мы будем использовать это значение в модуле, так как проверяются только старшие разряды:
 
if(current->mm)
 esp = current->mm->start_stack;
else
esp = 0;
вместо esp=regss->esp;
Заключение. Для увеличения защищенности системы не стоит обрабатывать только системный вызов sys_execve(). Предлагается перехватывать:
sys_fork()
sys_read()
sys_write()
sys_socket()
sys_open()
sys_creat()
sys_init_module()
sys_delete_module()
Думаю, что данного состава должно хватить для первой версии продукта.

Домашний Wi-Fi – ваша крепость или картонный домик?

Узнайте, как построить неприступную стену