Сколько раз и в каких только контекстах не писали об уязвимости переполнения буфера! Однако в этой статье я постараюсь предоставить универсальное практическое «вступление» для энтузиастов, начинающих погружение в низкоуровневую эксплуатацию, и на примере того самого переполнения рассмотрю широкий спектр тем: от существующих на данный момент механизмов безопасности компилятора GCC до точечных особенностей разработки бинарных эксплоитов для срыва стека.
Могу поспорить: со школьной скамьи тебе твердили, что 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 <СТРОКА>
Очевидно, все беды кроются в функции strcpy, прототип которой определен в заголовочном файле string.h.
strcpy
Функция strcpy занимается тем, что копирует содержимое массива символов src (далее для краткости я буду писать «строка») в предварительно подготовленный для этого буфер dst. В чем же, собственно, дело? В том, что нигде ни слова не сказано о длине исходной строки и о том, как она соотносится с размером выделенного под нее буфера.
Локальные статические переменные функций в большинстве случаев помещаются процессором в стек вызовов (или просто в «стек»), поэтому логично предположить, что именно стек используется потенциальным нарушителем в качестве площадки для своих злодеяний: если «вылезти» за легитимные границы памяти, можно натворить почти что угодно. Ведь «получить полный контроль над системой можно, только выйдя за ее пределы…».
Компиляция
Прежде чем копаться в стеке этой программы и дизассемблировать ее, разберемся с опциями, которые используются при компиляции. Так будет легче ориентироваться.
Я буду работать в Ubuntu 16.04.6 (i686) и использовать компилятор GCC версии 5.4.0. Вывод информации о версии ядра следующий.
Для демонстрационных целей этой статьи я, конечно, намеренно полностью обезоружу компилятор, отняв у него все фишки для защиты целостности потока выполнения программ.
Флаги, которые я использовал:
- -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 для предотвращения ее перезаписи в процессе [присваивания](https://en.wikipedia.org/wiki/Relocation_(computing) (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 настройки ядра.
Стек
Вспомним картинку, которую рисовали каждому юному девелоперу, где демонстрируется расположение данных в стеке. Для конкретики возьмем наш заведомо уязвимый исходник.
Размещение данных в стеке для функции main overflow.c
Два важных регистра процессора, которые участвуют в формировании стекового кадра, — это ESP и EBP.
- ESP — регистр общего назначения, указывающий на вершину стека. Как известно, стек растет вниз: при добавлении в него значения адрес ESP уменьшается, а при извлечении (снятии) из него значения адрес ESP, соответственно, увеличивается.
- EBP — регистр общего назначения, указывающий на базу стекового кадра и использующийся как своеобразное начало системы отсчета, связанной с текущим кадром. Значение EBP меняется, когда функция начинает или завершает свое выполнение, и в отличие от ESP, за изменение которого ответственен процессор, операции с EBP выполняет сама программа. К любому аргументу в стековом кадре, будь то локальная переменная или аргумент функции, можно легко получить доступ, используя адресацию типа
база (EBP) + смещение.
Также нельзя оставить без внимания служебный регистр EIP, который указывает на текущую инструкцию, исполняемую процессором. Адрес возврата — это, по сути, сохраненное значение регистра EIP, которое в дальнейшем будет использовано при возврате из функции инструкцией ret по ее завершении.
Но обо всем по порядку.
Ассемблер
Сейчас самое время рассмотреть ассемблерный код, генерируемый компилятором. Для этого, скомпилировав overflow.c командой выше, обратимся к отладчику GDB.
Чтобы получить листинг ассемблера, можно воспользоваться следующим однострочником.
Опция -batch говорит, что нужно выполнить команды без инициализации интерактивной сессии отладчика, которые, в свою очередь, передаются как значения аргументов -ex: открыть файл и дизассемблировать main. В качестве результата я получаю такой ассемблер с синтаксисом Intel.
Подобный результат можно также получить с помощью парсера объектных файлов objdump.
Разберем подробнее, что здесь происходит.
Первые три строки — классический пролог, в котором создается стековый фрейм: значение EBP вызывающей функции сохраняется в стеке и перезаписывается его текущей вершиной. Таким образом формируется своеобразная «зона комфорта» — мы можем обращаться к локальным сущностям в универсальном стиле независимо от того, что это за функция. Также здесь выделяется место под локальные переменные: прибавить к ESP знаковое значение 0xffffff80 — все равно, что вычесть из него 128 (как раз столько, сколько нам требуется для 128-байтного буфера buf).
Затем следуют приготовления для вызова функции strcpy. Сначала обработка «источника» — аргумент src из прототипа strcpy: в регистр EAX помещается строка, переданная пользователем и сохраненная в argv[1] (нулевая ячейка отводится под имя исполняемого файла), после чего значение самого регистра кладется в стек. Указатель на массив argv находится по смещению 12 (или 0xc) после адреса возврата и значения параметра argc.
Следом делается то же самое, но теперь для «назначения» — аргумент dst из прототипа strcpy: в регистр EAX загружается эффективный адрес указателя на начало массива buf, а инструкция lea (load effective address) используется для того, чтобы «на лету» вычислить смещение и поместить его в регистр.
Теперь все готово: можно вызвать функцию strcpy и очистить стек от двух не нужных более значений — src и dst.
Далее идет во многом схожая подготовка аргументов для функции печати введенной строки на экран.
Регистр EAX канонично обнуляется перед возвратом из функции.
И, наконец, самое интересное — и во многом то, что делает возможным изменение поведения программы, — эпилог.
Однако прежде чем переходить непосредственно к разбору структуры эксплоита, уделим внимание инструменту отладки GDB, с помощью которого был получен листинг ассемблера, и его модификации.