Содержание статьи
В королевстве PWN
В этом цикле статей мы изучаем разные аспекты атак типа «переполнение стека». Читай также:
Могу поспорить: со школьной скамьи тебе твердили, что strcpy
— это такая небезопасная функция, использование которой чревато попаданием в неблагоприятную ситуацию — выход за границы доступной памяти. Да и вообще «лучше используй Visual Studio». Почему эта функция небезопасна? Что может произойти, если ее использовать? Как эксплуатировать уязвимости семейства Stack-based Buffer Overflow? Ответы на эти вопросы я и дам далее.
Вот о чем конкретно пойдет речь.
- Обзор средств защиты, используемых компилятором GCC и операционными системами семейства Linux в целом.
- Работа с отладчиком GDB и прокачанным расширением PEDA.
- Анализ ассемблерного кода при разных опциях компиляции.
- Создание и тестирование шелл-кодов.
- Разработка эксплоитов для уязвимого к переполнению буфера исполняемого файла: перезапись адреса возврата, расположение полезной нагрузки в стеке, NOP-срезы.
- Использование дампов памяти ядра для эксплуатации уязвимости за пределами среды отладчика.
- Анализ нового пролога функции
main
и создание эксплоита, применимого для случая компиляции программы без флага-mpreferred-stack-boundary=2
.
Начнем, впереди долгое путешествие.
overflow.c
Итак, перед тобой есть исходник на языке 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: %s\n", buf);
return 0;
}
Очевидно, все беды кроются в функции strcpy
, прототип которой определен в заголовочном файле string.h
.
char *strcpy (char *dst, const char *src);
strcpy
Функция 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
Флаги, которые я использовал:
- -g — говорит компилятору включать в результат вспомогательную информацию для облегчения процесса отладки.
- -Wall -Werror — выводит предупреждения компилятора о возможной некорректности используемых в программе структур и, если таковые находятся, превращает их в ошибки, что делает компиляцию невозможной (в нашем примере, к слову, все хорошо, поэтому компилятор молчит).
- -O0 — отключает оптимизацию кода для чистоты эксперимента.
- -m32 — в явном виде подчеркивает, что мы хотим 32-битный исполняемый файл (в данном случае опция не необходима, так как мы сидим на 32-битном дистрибутиве и бинарник будет таковым по умолчанию, однако для наглядности полезно).
- -fno-stack-protector — отключает защиту компилятора от атак типа Stack Smashing. Это один из вариантов развития событий при эксплуатации уязвимости переполнения буфера. Под этим видом защиты обычно понимают небольшое расширение пространства стека для помещения непосредственно перед адресом возврата случайно сгенерированного целого числа, неизвестного нарушителю (это называется guard variable или canary — по аналогии с использованием канареек для выявления рудничного газа в шахтах). Если значение изменилось перед возвратом из функции, значит, велика вероятность, что произошло вмешательство извне и адрес возврата поврежден или подменен. Следовательно, необходимо остановить выполнение программы.
- -z execstack — опция, передаваемая компоновщику. Ключевое слово
execstack
означает, что инструкции, расположенные в стеке, могут быть выполнены. Такое поведение являлось вполне допустимым для некоторых архитектур и использовалось в целях оптимизации. Однако нам эта фишка понадобится, чтобы выполнить зловредный шелл-код, размещенный в пространстве стека. - -no-pie — опция компоновщика, указывающая на то, что мы не хотим позиционно-независимый исполняемый файл (PIE, Position Independent Execution), использующий рандомизацию адресного пространства (ASLR, Address Space Layout Randomization), которую в рамках этой статьи мы также отключим далее.
- -Wl,-z,norelro — и снова указание компоновщику: на это раз не помечать глобальную таблицу смещений (GOT, Global Offset Table) как Read-Only для предотвращения ее перезаписи в процессе присваивания (RELRO, Relocation Read-Only) значений адресам загрузки разделяемых библиотек.
- -mpreferred-stack-boundary=2 — оказывает влияние на размер выравнивания границ стекового фрейма. Выравнивание позволяет увеличить скорость обращения процессора к содержимому памяти, «добивая» размер стека до значения, кратного некоторому числу. Число же это есть
2^n
, гдеn
контролируется опцией-mpreferred-stack-boundary=n
. По дефолту в современных системахn
равно 4, то есть GCC построит стековые фреймы так, чтобы ESP для всех функций программы указывал на адреса, кратные 16 (2^4
). Для начала мы будем использовать значение2
, поэтому GCC будет выравнивать указатель стека на четырехбайтную границу. Для нас включение этой опции означает намного более читабельный листинг ассемблера, поскольку с приходом 16-байтных границ появился и новый пролог для функцииmain
, в котором черт ногу сломит с непривычки. Несмотря на это, в конце статьи мы посмотрим, что конкретно меняется при использовании этой опции, и проведем эксплуатацию без ее участия. - -o overflow — имя выходного файла.
overflow.c
— наконец, то, что мы компилируем.
Отлично, с аргументами разобрались. По правде говоря, такой обширный список не обязателен для демонстрации переполнения. Необходимый минимум — это -fno-stack-protector
и -z execstack
. Однако я решил перечислить как можно больше механизмов обеспечения безопасности исполняемых файлов, которые используются GCC. В следующих статьях я подробнее разберу упомянутые концепции защиты — и посмотрим, как можно их обойти.
Последнее, что нужно сделать в качестве подготовки, — это отключить ASLR. Сделать это можно с правами суперпользователя, внеся изменения в один из файлов procfs настройки ядра.
#
echo 0 > /proc/sys/kernel/randomize_va_space
Стек
Вспомним картинку, которую рисовали каждому юному девелоперу, где демонстрируется расположение данных в стеке. Для конкретики возьмем наш заведомо уязвимый исходник.
![Размещение данных в стеке для функции main overflow.c](https://xakep.ru/wp-content/uploads/2019/09/239524/stack-layout-main-boundary-2.png)
Два важных регистра процессора, которые участвуют в формировании стекового кадра, — это ESP и EBP.
- ESP — регистр общего назначения, указывающий на вершину стека. Как известно, стек растет вниз: при добавлении в него значения адрес ESP уменьшается, а при извлечении (снятии) из него значения адрес ESP, соответственно, увеличивается.
- EBP — регистр общего назначения, указывающий на базу стекового кадра и использующийся как своеобразное начало системы отсчета, связанной с текущим кадром. Значение EBP меняется, когда функция начинает или завершает свое выполнение, и в отличие от 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: %s\n"
0x0804843f <+36>: call 0x80482e0 <printf@plt> ; printf("Input: %s\n", 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, с помощью которого был получен листинг ассемблера, и его модификации.
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»