Сколько раз и в каких только контекстах не писали об уязвимости переполнения буфера! Однако в этой статье я постараюсь предоставить универсальное практическое «вступление» для энтузиастов, начинающих погружение в низкоуровневую эксплуатацию, и на примере того самого переполнения рассмотрю широкий спектр тем: от существующих на данный момент механизмов безопасности компилятора GCC до точечных особенностей разработки бинарных эксплоитов для срыва стека.
Могу поспорить: со школьной скамьи тебе твердили, что strcpy
— это такая небезопасная функция, использование которой чревато попаданием в неблагоприятную ситуацию — выход за границы доступной памяти. Да и вообще «лучше используй Visual Studio». Почему эта функция небезопасна? Что может произойти, если ее использовать? Как эксплуатировать уязвимости семейства Stack-based Buffer Overflow? Ответы на эти вопросы я и дам далее.
Вот о чем конкретно пойдет речь.
main
и создание эксплоита, применимого для случая компиляции программы без флага -mpreferred-stack-boundary=2
.Начнем, впереди долгое путешествие.
Итак, перед тобой есть исходник на языке C. Файл называется overflow.c
и реализует простые функции: копирование полученной от пользователя строки в локальный буфер и вывод содержимого последнего на экран. Что с ним не так?
overflow.c
gcc -g -Wall -Werror -O0 -m32 -fno-stack-protector -z execstack -no-pie -Wl,-z,norelro -mpreferred-stack-boundary=2 -o overflow overflow.c
./overflow <СТРОКА>
#include <stdio.h> #include <string.h> int main(int argc, char* argv[]) { // 128-байтный массив типа char char buf[128]; // копирование первого аргумента в массив buf strcpy(buf, argv[1]); // вывод содержимого буфера на экран printf("Input: %sn", buf); return 0; }
Очевидно, все беды кроются в функции strcpy
, прототип которой определен в заголовочном файле string.h
.
char *strcpy (char *dst, const char *src);
Функция strcpy
занимается тем, что копирует содержимое массива символов src
(далее для краткости я буду писать «строка») в предварительно подготовленный для этого буфер dst
. В чем же, собственно, дело? В том, что нигде ни слова не сказано о длине исходной строки и о том, как она соотносится с размером выделенного под нее буфера.
Локальные статические переменные функций в большинстве случаев помещаются процессором в стек вызовов (или просто в «стек»), поэтому логично предположить, что именно стек используется потенциальным нарушителем в качестве площадки для своих злодеяний: если «вылезти» за легитимные границы памяти, можно натворить почти что угодно. Ведь «получить полный контроль над системой можно, только выйдя за ее пределы…».
Прежде чем копаться в стеке этой программы и дизассемблировать ее, разберемся с опциями, которые используются при компиляции. Так будет легче ориентироваться.
Я буду работать в Ubuntu 16.04.6 (i686) и использовать компилятор GCC версии 5.4.0. Вывод информации о версии ядра следующий.
$ uname -a Linux pwn-ubuntu 4.15.0-58-generic #64~16.04.1-Ubuntu SMP Wed Aug 7 14:09:34 UTC 2019 i686 i686 i686 GNU/Linux
Для демонстрационных целей этой статьи я, конечно, намеренно полностью обезоружу компилятор, отняв у него все фишки для защиты целостности потока выполнения программ.
$ gcc -g -Wall -Werror -O0 -m32 -fno-stack-protector -z execstack -no-pie -Wl,-z,norelro -mpreferred-stack-boundary=2 -o overflow overflow.c
Флаги, которые я использовал:
execstack
означает, что инструкции, расположенные в стеке, могут быть выполнены. Такое поведение являлось вполне допустимым для некоторых архитектур и использовалось в целях оптимизации. Однако нам эта фишка понадобится, чтобы выполнить зловредный шелл-код, размещенный в пространстве стека.2^n
, где n
контролируется опцией -mpreferred-stack-boundary=n
. По дефолту в современных системах n
равно 4, то есть GCC построит стековые фреймы так, чтобы ESP для всех функций программы указывал на адреса, кратные 16 (2^4
). Для начала мы будем использовать значение 2
, поэтому GCC будет выравнивать указатель стека на четырехбайтную границу. Для нас включение этой опции означает намного более читабельный листинг ассемблера, поскольку с приходом 16-байтных границ появился и новый пролог для функции main
, в котором черт ногу сломит с непривычки. Несмотря на это, в конце статьи мы посмотрим, что конкретно меняется при использовании этой опции, и проведем эксплуатацию без ее участия.overflow.c
— наконец, то, что мы компилируем.Отлично, с аргументами разобрались. По правде говоря, такой обширный список не обязателен для демонстрации переполнения. Необходимый минимум — это -fno-stack-protector
и -z execstack
. Однако я решил перечислить как можно больше механизмов обеспечения безопасности исполняемых файлов, которые используются GCC. В следующих статьях я подробнее разберу упомянутые концепции защиты — и посмотрим, как можно их обойти.
Последнее, что нужно сделать в качестве подготовки, — это отключить ASLR. Сделать это можно с правами суперпользователя, внеся изменения в один из файлов procfs настройки ядра.
$ echo 0 > /proc/sys/kernel/randomize_va_space
Вспомним картинку, которую рисовали каждому юному девелоперу, где демонстрируется расположение данных в стеке. Для конкретики возьмем наш заведомо уязвимый исходник.
Два важных регистра процессора, которые участвуют в формировании стекового кадра, — это ESP и EBP.
база (EBP) + смещение
.Также нельзя оставить без внимания служебный регистр EIP, который указывает на текущую инструкцию, исполняемую процессором. Адрес возврата — это, по сути, сохраненное значение регистра EIP, которое в дальнейшем будет использовано при возврате из функции инструкцией ret
по ее завершении.
Но обо всем по порядку.
Сейчас самое время рассмотреть ассемблерный код, генерируемый компилятором. Для этого, скомпилировав overflow.c
командой выше, обратимся к отладчику GDB.
Чтобы получить листинг ассемблера, можно воспользоваться следующим однострочником.
$ gdb -batch -ex 'file ./overflow' -ex 'disas main'
Опция -batch
говорит, что нужно выполнить команды без инициализации интерактивной сессии отладчика, которые, в свою очередь, передаются как значения аргументов -ex
: открыть файл и дизассемблировать main
. В качестве результата я получаю такой ассемблер с синтаксисом Intel.
Dump of assembler code for function main: 0x0804841b <+0>: push ebp 0x0804841c <+1>: mov ebp,esp 0x0804841e <+3>: add esp,0xffffff80 0x08048421 <+6>: mov eax,DWORD PTR [ebp+0xc] 0x08048424 <+9>: add eax,0x4 0x08048427 <+12>: mov eax,DWORD PTR [eax] 0x08048429 <+14>: push eax 0x0804842a <+15>: lea eax,[ebp-0x80] 0x0804842d <+18>: push eax 0x0804842e <+19>: call 0x80482f0 <strcpy@plt> 0x08048433 <+24>: add esp,0x8 0x08048436 <+27>: lea eax,[ebp-0x80] 0x08048439 <+30>: push eax 0x0804843a <+31>: push 0x80484d0 0x0804843f <+36>: call 0x80482e0 <printf@plt> 0x08048444 <+41>: add esp,0x8 0x08048447 <+44>: mov eax,0x0 0x0804844c <+49>: leave 0x0804844d <+50>: ret End of assembler dump.
Подобный результат можно также получить с помощью парсера объектных файлов objdump.
$ objdump -M intel -d ./overflow | grep '<main>:' -A19
Разберем подробнее, что здесь происходит.
0x0804841b <+0>: push ebp 0x0804841c <+1>: mov ebp,esp 0x0804841e <+3>: add esp,0xffffff80 // эквивалентно "sub esp,0x80"
Первые три строки — классический пролог, в котором создается стековый фрейм: значение EBP вызывающей функции сохраняется в стеке и перезаписывается его текущей вершиной. Таким образом формируется своеобразная «зона комфорта» — мы можем обращаться к локальным сущностям в универсальном стиле независимо от того, что это за функция. Также здесь выделяется место под локальные переменные: прибавить к ESP знаковое значение 0xffffff80
— все равно, что вычесть из него 128 (как раз столько, сколько нам требуется для 128-байтного буфера buf
).
0x08048421 <+6>: mov eax,DWORD PTR [ebp+0xc] // eax = argv 0x08048424 <+9>: add eax,0x4 // eax = &argv[1] 0x08048427 <+12>: mov eax,DWORD PTR [eax] // eax = argv[1] 0x08048429 <+14>: push eax // подготовить аргумент "src" для функции strcpy
Затем следуют приготовления для вызова функции strcpy
. Сначала обработка «источника» — аргумент src
из прототипа strcpy
: в регистр EAX помещается строка, переданная пользователем и сохраненная в argv[1]
(нулевая ячейка отводится под имя исполняемого файла), после чего значение самого регистра кладется в стек. Указатель на массив argv
находится по смещению 12 (или 0xc
) после адреса возврата и значения параметра argc
.
0x0804842a <+15>: lea eax,[ebp-0x80] // eax = buf 0x0804842d <+18>: push eax // подготовить аргумент "dst" для функции strcpy
Следом делается то же самое, но теперь для «назначения» — аргумент dst
из прототипа strcpy
: в регистр EAX загружается эффективный адрес указателя на начало массива buf
, а инструкция lea
(load effective address) используется для того, чтобы «на лету» вычислить смещение и поместить его в регистр.
0x0804842e <+19>: call 0x80482f0 <strcpy@plt> // strcpy(src, dst) или strcpy(buf, argv[1]) 0x08048433 <+24>: add esp,0x8 // очистить стек от двух крайних значений по 4 байта каждое
Теперь все готово: можно вызвать функцию strcpy
и очистить стек от двух не нужных более значений — src
и dst
.
0x08048436 <+27>: lea eax,[ebp-0x80] // eax = buf 0x08048439 <+30>: push eax // подготовить аргумент "buf" для функции printf 0x0804843a <+31>: push 0x80484d0 // подготовить строку формата "Input: %sn" 0x0804843f <+36>: call 0x80482e0 <printf@plt> // printf("Input: %sn", buf) 0x08048444 <+41>: add esp,0x8 // очистить стек от крайнего значения
Далее идет во многом схожая подготовка аргументов для функции печати введенной строки на экран.
0x08048447 <+44>: mov eax,0x0 // eax = 0x0
Регистр EAX канонично обнуляется перед возвратом из функции.
И, наконец, самое интересное — и во многом то, что делает возможным изменение поведения программы, — эпилог.
0x0804844c <+49>: leave // mov esp,ebp; pop ebp 0x0804844d <+50>: ret // eip = esp
Здесь leave
разворачивается не во что иное, как в цепочку из двух инструкций — mov esp,ebp; pop ebp
. Этим действием мы в точности «откатываем» то, что было сделано при создании стекового кадра: вершина стека вновь указывает на значение, которое она содержала перед входом в функцию, а EBP опять принимает значение EBP вызывающей функции. После этого выполняется инструкция ret
, которая, в сущности, берет верхнее значение стека, присваивает его регистру EIP, предполагая, что это сохраненный адрес возврата в вызывающую функцию, переходит по этому адресу, не ожидая недоброго, и все вернулось бы на круги своя... Если бы здесь в игру не вступили мы.
Однако прежде чем переходить непосредственно к разбору структуры эксплоита, уделим внимание инструменту отладки GDB, с помощью которого был получен листинг ассемблера, и его модификации.
Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», увеличит личную накопительную скидку и позволит накапливать профессиональный рейтинг Xakep Score! Подробнее
1 год7690 р. |
1 месяц720 р. |
Я уже участник «Xakep.ru»
Читайте также
Последние новости