В королевстве PWN. Препарируем классику переполнения буфера в современных условиях

Сколько раз и в каких только контекстах не писали об уязвимости переполнения буфера! Однако в этой статье я постараюсь предоставить универсальное практическое «вступление» для энтузиастов, начинающих погружение в низкоуровневую эксплуатацию, и на примере того самого переполнения рассмотрю широкий спектр тем: от существующих на данный момент механизмов безопасности компилятора 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 <СТРОКА>
#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

Функция 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 для предотвращения ее перезаписи в процессе [присваивания](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 настройки ядра.

$ echo 0 > /proc/sys/kernel/randomize_va_space

 

Стек

Вспомним картинку, которую рисовали каждому юному девелоперу, где демонстрируется расположение данных в стеке. Для конкретики возьмем наш заведомо уязвимый исходник.

Размещение данных в стеке для функции main overflow.c

Два важных регистра процессора, которые участвуют в формировании стекового кадра, — это 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: %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

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *