Коллектив авторов - Защита от хакеров корпоративных сетей
Пример программы переполнения буфера для Linux
В последнее время феноменально выросла популярность Linux. Несмотря на доступные исходные тексты и армию разработчиков открытого программного обеспечения, до сих пор нельзя сказать, что в Linux исправлены все ошибки. Часто по вине пользователя переполнение буфера происходит в программах, которые непосредственно не связаны с безопасностью системы. Далее особое внимание будет обращено на способы, которые могут быть использованы в многочисленных ситуациях, в том числе и связанных с безопасностью.
На примере программного кода записи строки на экран будет продемонстрировано последовательное расширение функциональных возможностей программы переполнения буфера. Пример подобен простой программе на языке C, использующей функцию write().
Сначала создадим простую программу, выводящую строку на экран:
–write.c–
int main()
{
write(1,»EXAMPLEn»,10);
}
–write.c–Сохраним исходный текст в файле write.c, откомпилируем его компилятором GCC и выполним.
bash$ gcc write.c -o example —static
bash$ ./example
EXAMPLE
bash$Все достаточно просто. Для того чтобы окончательно понять работу программы, воспользуемся утилитой gdb. У утилиты gdb больше возможностей, чем читатель может себе представить. Если он знает их все, то ему нужно сменить хобби. Для изучения примера достаточно основных возможностей утилиты gdb. Для начала откроем пример программы:
–
bash$ gdb ./example
GNU gdb 5.1
Copyright 2001 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public
License, and you are welcome to change it and/or 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.
This GDB was configured as “i686-pc-linux-gnu”...
(gdb)
–Может оказаться, что версия утилиты gdb читателя отличается от используемой в книге. Но это не имеет большого значения. Без всякого сомнения, используемые возможности утилиты gdb реализованы в версии утилиты читателя. Введем в ответ на приглашение утилиты команду disassemble main и исследуем выполняемый код программы в функции main(), обратив особое внимание на участок кода, который вызывает функцию write(). Команда disassemble выводит код функции на языке ассемблера используемого компьютера. Для нашего примера это Intel x86.
(gdb) disas main
Dump of assembler code for function main:
0x80481e0 <main>: push %EBP
0x80481e1 <main+1>: mov %ESP,%EBP
0x80481e3 <main+3>: sub $0x8,%ESP
0x80481e6 <main+6>: sub $0x4,%ESP
0x80481e9 <main+9>: push $0x9
0x80481eb <main+11>: push $0x808e248
0x80481f0 <main+16>: push $0x1
0x80481f2 <main+18>: call 0x804cc60 <__libc_write>
0x80481f7 <main+23>: add $0x10,%ESP
0x80481fa <main+26>: leave
0x80481fb <main+27>: ret
End of assembler dump.
(gdb)Далее будет исследован выполняемый код функции write(). Параметры функции write() записываются в стек в обратном порядке. Сначала командой push $0x9 в стек проталкивается величина 0x9 (символ $0x указывает на представление утилитой gdb выводимых величин в шестнадцатеричном виде), где 9 – длина строки «EXAMPLEn». Далее в стек командой push $0x808e248 проталкивается адрес строки «EXAMPLEn». Для просмотра содержимого области по этому адресу достаточно в ответ на приглашение gdb ввести команду утилиты: x/s 0x808e248. Заключительный шаг перед вызовом функции write() состоит в записи в стек дескриптора файла. В данном случае это 1 – дескриптор стандартного вывода. После перечисленных действий вызывается функция write().
0x80481e9 <main+9>: push $0x9
0x80481eb <main+11>: push $0x808e248
0x80481f0 <main+16>: push $0x1
0x80481f2 <main+18>: call 0x804cc60 <__libc_write>Для просмотра кода функции write() в ответ на приглашение утилиты введем команду disas__libc_write . Получим следующее.
(gdb) disas __libc_write
Dump of assembler code for function __libc_write:
0x804cc60 <__libc_write>: push %EBX
0x804cc61 <__libc_write+1>: mov 0x10(%ESP,1),%EDX
0x804cc65 <__libc_write+5>: mov 0xc(%ESP,1),%ECX
0x804cc69 <__libc_write+9>: mov 0x8(%ESP,1),%EBX
0x804cc6d <__libc_write+13>: mov $0x4,%EAX
0x804cc72 <__libc_write+18>: int $0x80
0x804cc74 <__libc_write+20>: pop %EBX
0x804cc75 <__libc_write+21>: cmp $0xfffff001,%EAX
0x804cc7a <__libc_write+26>: jae 0x8052bb0 <__syscall_error>
0x804cc80 <__libc_write+32>: ret
End of assembler dump.Начальная команда push %EBX не так важна. Она сохраняет в стеке старое значение регистра EBX. В программе значение регистра изменяется, а затем восстанавливается командой pop %EBX. Еораздо интереснее последующие команды mov и int $0x80. Первые три команды mov переписывают данные, ранее сохраненные в стеке функцией main (), в рабочие регистры. Четвертая команда mov подготавливает вызов функции write(), помещая номер системного вызова в регистр EAX. При выполнении команды int $0x80 операционная система передает управление программе системного вызова по номеру, записанному в регистре EAX. Номер системного вызова функции write() – 4. В файле «/usr/include/asm/unistd.h» перечислены все номера доступных системных вызовов.
0x804cc6d <__libc_write+13>: mov $0x4,%EAX 0x804cc72 <__libc_write+18>: int $0x80
Подведем итоги. Теперь известно, что функции write() передается три параметра: длина записываемых данных, адрес строки источника, из которой переписываются данные, и адресат записи – дескриптор файла. Также теперь известно, что длина строки, в данном случае 9 байт, передается через регистр EDX, адрес строки записываемых данных через регистр ECX и дескриптор файла должен быть передан через регистр EBX. Таким образом, простой код вызова функции write() без обработки ошибок выглядит следующим образом:
mov $0x9,%EDX
mov 0x808e248,%ECX
mov $0x1,%EBX
mov $0x4,%EAX
int $0x80Зная ассемблерный вид вызова функции write(), можно приступить к написанию управляющего кода (shellcode). Единственная сложность заключается во второй команде mov 0x808e248,%ECX с явно заданным адресом памяти. Проблема состоит в том, что нельзя прочитать из строки данные, не зная ее адрес, но нельзя узнать адрес строки, пока она не будет загружена в память. Для ее разрешения применима последовательность команд jmp/call. Найденное решение основано на алгоритме работы команды call: по команде call в стек записывается адрес следующей команды. Поэтому выход из трудного положения может быть следующим:
jump <string>
code:
pop %ECX
string:
call <code>
“our stringn”По команде call в стек записывается адрес следующей команды и выполняется переход по указанной метке. На самом деле в стек загружается адрес строки, но для выполнения команды это безразлично. В результате на вершине стека оказывается адрес строки stringn. После перехода на метку code выполняется команда pop %ECX. Команда pop переписывает в заданный регистр данные с вершины стека. В данном случае в регистр ECX записывается адрес строки stringn. Осталось только для правильной работы программы очистить (обнулить) регистры от посторонних данных. Очистка регистров выполняется командами операция исключающее ИЛИxor или вычитания sub. Лучше использовать команду xor, потому что команда xor всегда обнуляет регистр и транслируется в быстрый компактный код. В системных вызовах для передачи параметров используются младшие байты регистров, поэтому обнуление регистров гарантирует правильную передачу параметров. В итоге фрагмент программы приобрел следующий вид:
jump string
code:
pop %ECX
xor %EBX, %EBX
xor %EDX, %EDX
xor %EAX, %EAX
mov $0x9,%EDX
mov $0x1,%EBX
mov $0x4,%EAX
int $0x80
string:
call code
“EXAMPLEn”После завершения работы над фрагментом управляющего кода следует решить вопрос о передачи ему управления из программы переполнения буфера. Для этого нужно подменить сохраненное в стеке значение регистра EIP на адрес управляющего кода. Когда функция bof() уязвимой программы попытается вернуться в функцию main по команде ret, она восстановит из стека сохраненное там значение регистра EIP и по команде перехода jmp перейдет по восстановленному адресу. Но где в памяти будет расположен управляющий код? Конкретнее, на какой адрес нужно подменить содержимое регистра EIP, сохраненное в стеке?
При помощи функции fread() данные из файла считываются в размещенный в стеке восьмибайтовый буфер buffer. Известно, что программный код полезной нагрузки в конечном счете будет загружен из файла в стек. В UNIX-подобных системах во всех программах стек начинается с одного и того же адреса. Поэтому последнее, что осталось сделать, – это написать программу определения смещения области размещения программного кода полезной нагрузки в стеке относительно его начала.
Перед завершением своей работы функция передает вызвавшей ее программе код возврата в регистре EAX, чтобы та знала об успешном или неуспешном выполнении функции. Чтобы узнать ассемблерную реализацию фрагмента программы, отвечающего за передачу кода завершения, оттранслируем и дизассемблируем следующую программу: