X

Эксплуатация уязвимостей исполняемых файлов для новичков: переполнение буфера стека

В этой статье будет рассмотрена атака переполнения буфера стека, с подробным описанием того, что это такое, как это работает и какие технологии используются для её реализации. Статья написана так, чтобы тему смог понять мог даже неподготовленный читатель.

Теория: память приложений

При выполнении приложения загружаются в память. Но как мы все знаем, компьютеры имеют ограниченное количество памяти, и приложению нужно быть очень осторожным, чтобы не перезаписать область в памяти, принадлежащую другому приложению. Для того, чтобы избежать такой ситуации, используется так называемая виртуальная память, которую можно прекрасно описать с помощью сцены из сериала, выпущенного в начале 2000-х — Дрейк и Джош, в котором главные герои раскладывают суши по контейнерам:

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

Прим. перев. Посмотреть серию можно здесь, сцена с суши на 18 минуте.

Давайте перейдём от аналогии к концепции виртуальной памяти.

Конвейер с суши: как уже говорилось ранее, компьютеры имеют ограниченную память и должны быть очень осторожны и точны при записи данных, чтобы не перезаписать память других приложений. Хоть компьютер и может просто записать приложение в физическую память, в конечном итоге это приведёт к проблемам, так как фрагменты приложений быстро заполнят всю свободную память. В примере выше индивидуальные сушинки можно рассматривать как фрагменты приложения или часть памяти, занятой определённым приложением, а набор суши из шести штук — само приложение.

Дрейк и Джош: чтобы обойти проблему с заполнением конвейера одиночными сушинками, главные герои разбирают их по разным контейнерам, которые потом отправляют дальше по конвейеру. Ваш компьютер, как Дрейк и Джош, распределяет приложения по контейнерам, называемым виртуальным адресным пространством. Такие виртуальные адресные пространства позволяют приложению считать, что оно имеет полный контроль над всей памятью устройства. Однако когда приложение пытается получить доступ к памяти вне своего виртуального адресного пространства, маленькая, но чрезвычайно важная часть вашего ЦПУ — блок управления памятью — перенаправляет обращение к физической памяти в соответствии с выделенной областью для этого приложения, облегчая таким образом любую работу с памятью. Такое распределение позволяет компьютеру организовать работу сразу нескольких приложений через справочную таблицу с требованиями в виде динамической памяти.

ASCII-диаграмма работы виртуальной памяти

Также важно знать, что код всех приложений содержится внутри их виртуального адресного пространства. Приложения часто используют динамически подключаемые библиотеки (DLL), такие как libc или kernel32. DLL — внешние (т. е. не хранятся в исполняемом файле запускаемого приложения) системные или специально написанные библиотеки, которыми пользуется приложение. Пример представлен ниже.

int main()
{
    printf("Hello World");

    return 0;
}

Как вы видите, нигде в этой шестистрочной программе не объявляется printf(). Однако эта программа всё равно запустится без ошибок и выведет «Hello World». Это потому, что функция printf() — системная функция, определённая в libc, стандартной библиотеке C. Во время компиляции libc внешне подключается к исполняемому файлу. В системах Linux можно посмотреть используемые программой библиотеки с помощью команды ldd.

Отображение используемых программой библиотек с помощью ldd

Если вы смотрите на скриншот выше и думаете, что такое 0xb7e99000, то это адрес библиотеки libc в памяти. Адреса в памяти представлены в шестнадцатеричном формате. Более подробно о шестнадцатеричном формате вы можете узнать здесь.

Теория: стек

Стек — это просто большая структура данных, которая используется для хранения приложением информации или данных во время её работы. Работу стека можно описать следующей аналогией:

Боб — мойщик посуды в модном ресторане. Каждый вечер у Боба есть стопка тарелок, которые нужно вымыть. Более того, в течение ночи на верх стопки могут добавляться ещё тарелки, по мере уборки столов. Если Боб возьмёт тарелку откуда-то ещё кроме как сверху стопки, то вся стопка развалится и тарелки разобьются.

А теперь вместо Боба и стопки тарелок представьте компьютер и стопку объектов с данными. Когда что-то добавляется (push) в стек, оно кладётся наверх стопки. Когда извлекается (pull), то берётся сверху стопки. Так работает механизм LIFO (Last In First Out, последним пришёл — первым вышел).

Стек используется программами для хранения различных вещей, например, указателей на функции и переменных.

Теория: вызов функций и возвраты

Посмотрите на код, представленный ниже:

int add(int A, int B){
    return A + B;
}
int main(){
    add(1, 2);
}

В этом фрагменте кода мы видим что функция add() принимает два аргумента целочисленного типа, имена которых А и В. В функции main() мы вызываем функцию add() с 1 в качестве аргумента А и 2 — В. Если перевести это в машинный код:

push 2
push 1
call add

Как вы видите, при вызове функции с параметрами программа сначала добавляет оба параметра в стек, а затем выполняет команду call. Команда call перенаправляет указатель инструкции программы по адресу вызываемой функции. Указатель программы подобен маленькому карандашу, который вы используете для отслеживания слов при чтении. Указатель инструкции всегда указывает на ту инструкцию, которая должна быть выполнена (слово, которое будет прочитано). Однако, перед тем, как перейти к вызываемой функции, команда call помещает адрес следующей за ним инструкции в стек, чтобы, когда произойдёт возврат из функции add(), было известно, с чего продолжать выполнение программы. Адрес места, в которое функция должна вернуться, называется указателем возврата функции.

Атака: переполнение буфера стека

Прежде чем углубляться в технические подробности о том, что такое переполнение стекового буфера и как оно работает, давайте рассмотрим простую для понимания аналогию:

Алиса и Боб раньше встречались, но в итоге Алиса рассталась с Бобом. Время шло, Алиса оставила прошлое позади, но Бобу так и не удалось справиться с горем. Теперь Алиса выходит замуж за Роберта Хакермана, заклятого врага Боба. Боб, будучи жутким чудаком, следил за всеми свадебными планами Алисы благодаря своему секретному доступу к электронной почте Алисы. Боб увидел, что Алиса наняла известного дизайнера свадебных тортов, который хотел, чтобы Алиса отредактировала части своего рецепта под свои вкусовые предпочтения. Дизайнер дал Алисе рекомендованный список ингредиентов, которые можно добавить, но сказал, что сделает всё в точности, как она захочет. Боб открыл документ, прикреплённый к электронному письму дизайнера, и увидел, что меняемые строки рецепта выглядят так:

… Затем мы наделим глазурь вкусом, добавив _____. После этого мы добавим немного шоколада …

Боб заметил, что если ввести «банан» в строку, текст будет выглядеть так:

… Затем мы наделим глазурь вкусом, добавив банан. После этого мы добавим немного шоколада …

Но если Боб введёт «клубника» в строку, текст будет выглядеть так:

… Затем мы наделим глазурь вкусом, добавив клубникуосле этого мы добавим немного шоколада …

Боб понял, что это будет идеальный способ испортить свадьбу Алисы, и всё, что ему нужно было сделать, это переписать остальную часть рецепта своей собственной отвратительной версией! В день свадьбы Алисы дизайнер наконец-то показал торт, который он сделал — он был покрыт жуками и сделан из замороженного майонеза!

Переполнение буфера стека, как и атака Боба, перезаписывает данные, которые разработчик не собирался перезаписывать, обеспечивая полный контроль над программой и её выходными данными.

Итак, теперь давайте посмотрим на это в реальном мире. Взгляните на следующий фрагмент кода:

#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>

int main(int argc, char **argv)
{
    volatile int modified;
    char buffer[64];

    modified = 0;
    gets(buffer);
   
    if(modified != 0) {
        printf("You have changed the 'modified' variable\n");
    } else {
        printf("Try again?\n");
    }
}

В приведённой выше функции мы видим, что массив символьного типа с именем buffer создаётся с размером 64. Затем мы видим, что переменная modified равна 0, и функция gets() вызывается с переменной buffer в качестве аргумента. Наконец, мы видим оператор IF, который проверяет, не равно ли значение modified нулю. Очевидно, что нет, где в этом приложении переменная modified имеет значение, отличное от 0. Так как мы собираемся её изменить?

Что ж, давайте сначала посмотрим на документацию функции gets():

Определение функции gets()

Описание багов в функции gets()

Как видите, функция gets() просто принимает пользовательский ввод. Однако функция не проверяет, действительно ли пользовательский ввод вписывается в структуру данных, в которой мы его храним (в данном случае это buffer), и, таким образом, мы можем переполнить структуру данных и повлиять на другие переменные и данные стека. Кроме того, поскольку мы знаем, что все переменные хранятся в стеке, и мы знаем, что представляет собой переменная modified (0), всё, что нам нужно сделать, — это ввести достаточно данных, чтобы перезаписать переменную modified. Давайте посмотрим на диаграмму:

ASCII-диаграмма переполнения буфера стека

Как видите, если злоумышленник просто вводит слишком много текста, он может перезаписать переменную modified и всё остальное в стеке, включая указатели возврата. Это означает, что если злоумышленник сможет взять под контроль стек программы, он сможет эффективно контролировать всю программу и заставить её делать то, что он хочет. Например, можно просто перезаписать указатель возврата функции в стеке на пользовательский, указывающий на вредоносную функцию.

Атака: ret2libc

Как мы знаем из первого раздела, libc — это стандартная библиотека языка C. Это означает, что она содержит все общие системные функции, включённые в язык программирования C. Теперь, что если злоумышленник сможет взять под контроль программу для выполнения некоторых из этих функций?

Это и есть ret2libc. Одной идеальной аналогией для последствий ret2libc может быть серия Матрицы. Вспомните классическую сцену «Оружие, много оружия». Оператор Танк смог полностью обойти и перепрограммировать матрицу, чтобы МОРЕ оружия просто появилось из ниоткуда.

Вы можете думать о возврате в libc вот так: мы можем взять под контроль матрицу (стандартную библиотеку C) и заставить её делать то, что мы хотим.

По сути, атаки ret2libc фактически основаны на переполнении стекового буфера. Вспомните, что было сказано в конце предыдущего раздела: если злоумышленник может перезаписывать данные в стеке, он может просто перезаписать указатель возврата, чтобы указать на конкретную функцию в libc, и передать ей любые аргументы, необходимые для доставки полезной нагрузки.

Одной из самых распространённых функций для атак ret2libc является функция system(). Давайте посмотрим на документацию:

Документация по функции system()

Как видите, функция system() просто выполняет shell-команды (shell — это командная строка Linux). Более того, если мы прочитаем описание, то увидим, что система просто выполняет /bin/sh -c <команда>, и команда передаётся в функцию через аргумент.

Итак, всё, что нам нужно сделать, чтобы получить доступ из командной строки к компьютеру, на котором запущено уязвимое приложение, — это вставить «/bin/sh» в стек в качестве аргумента, а затем заменить указатель возврата или вызова адресом памяти функции system(), так чтобы эта функция вызывалась с /bin/sh в качестве аргумента, запускала оболочку и предоставляла нам полный доступ через систему.

Как видите, эксплуатация даже такой, казалось бы простой уязвимости, может привести к катастрофическим последствиям для машины, где находится уязвимый файл. В следующем материале будут описаны другие уязвимости и способы защиты от них.

Перевод статьи «Binary Exploitation ELI5– Part 1 »

Варвара Николаева

, последний центурион

Также рекомендуем:

Рубрика: Переводы