Что такое переполнение буфера (Buffer Overflow)?

переполнение буфера

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


Мы проанализируем простое уязвимое приложение, которое не выполняет надлежащей обработки (санации) пользовательского ввода. Мы проанализируем исходный код этого приложения и специальным образом переполним стек. Это переполнение приведет к повреждению данных в стеке, что в конечном итоге приведет к перезаписи адреса возврата и полному контролю над регистром EIP.


Пример уязвимого кода


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


#include <stdio.h>

#include <string.h>

int main(int argc, char *argv[])

{

char buffer[64];

if (argc < 2)

{

printf("ERROR - Vy dolzhny predostavit kak minimum odin argument\n"); return 1;

}

strcpy(buffer, argv[1]);

return 0;

}


Даже если вы никогда раньше не имели дела с кодом на языке Си, вам будет довольно легко понять логику, показанную в приведенном листинге. Прежде всего, стоит отметить, что в Си функция main может принимать аргументы, возвращать значения и т.д. Единственное отличие от других функций заключается в том, что она "вызывается" самой операционной системой при запуске процесса. В данном случае функция main сначала определяет символьный массив с именем buffer, который может вмещать до 64 символов. Поскольку массив определен внутри функции, компилятор языка Си будет рассматривать его как локальную переменную и зарезервирует для нее место (64 байта) на стеке. В частности, это пространство памяти будет зарезервировано в стековом фрейме главной функции во время ее выполнения.


P.S. Локальные переменные имеют локальную область видимости. Это означает, что они доступны только в пределах функции или блока кода, в котором они объявлены. В отличие от них, глобальные переменные хранятся в сегменте данных программы (.data) и доступны всему коду приложения.


Затем программа копирует (strcpy) содержимое заданного аргумента командной строки (argv[1]) в символьный массив buffer.


P.S. Обратите внимание, что язык Си не поддерживает строки как тип данных. На низком уровне, строка - это последовательность символов, завершающаяся null-символом ('\0').


Далее, программа завершает свое выполнение и возвращает операционной системе ноль (стандартный код успешного завершения). При вызове этой программы мы будем передавать ей аргументы командной строки (аргумент командной строки — это информация, которая вводится в командной строке операционной системы вслед за именем программы). Главная функция обрабатывает эти аргументы с помощью, argc и argv. Если аргумент, переданный в главную функцию, состоит из 64 (1 cимвол = 1 байт) символов или меньше, программа будет работать корректно. Но, поскольку размер входных данных не проверяется, если аргумент больше, скажем, 80 байт, часть стека будет перезаписана оставшимися 16 символами. Это показано на следующем рисунке:

переполнение буфера windows

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


Immunity Debugger


Мы можем использовать приложение, называемое отладчиком, чтобы помочь себе в процессе разработки эксплойта. Отладчик действует как посредник между приложением и центральным процессором, и он позволяет нам остановить поток выполнения в любое время, чтобы просмотреть содержимое регистров и памяти процесса. Запуская приложение через отладчик, мы можем выполнять ассемблерные инструкции по одной, чтобы лучше понимать ход выполнения кода. Существует множество отладчиков. Мы будем использовать Immunity Debugger, который имеет относительно простой интерфейс и позволяет нам использовать скрипты Python для автоматизации задач.


P.S. В течении курса мы будем использовать разные отладчики.


Мы можем попытаться переполнить буфер в нашем уязвимом тестовом приложении и использовать Immunity Debugger, чтобы лучше понять, что именно происходит на каждом этапе выполнения программы. Чтобы запустить Immunity и открыть в нем уязвимое приложение, мы запустим Immunity и перейдем в меню Файл > Открыть, как показано на следующем рисунке:

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

Далее открываем файл strcpy.exe (СКАЧАТЬ strcpy.exe), который является скомпилированной версией исходного кода, проанализированного ранее. Перед тем как нажать кнопку Открыть, мы добавим двенадцать символов "A" в поле Arguments (Аргументы), как показано на рисунке ниже. Эти 12 символов будут служить аргументом командной строки программы и впоследствии будут использованы функцией strcpy:

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

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

механизм переполнения буфера

В верхнем левом окне отображаются инструкции, из которых состоит приложение. Инструкция выделенная синим цветом является инструкцией ассемблера, которая будет выполняться следующей:

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

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

переполнение буфера

В правом нижнем окне показан стек и его содержимое. Это представление содержит четыре столбца: адрес памяти, шестнадцатеричные данные, расположенные по этому адресу, ASCII-представление этих данных и комментарий (если он имеется), который предоставляет дополнительную информацию. Сами данные (второй столбец) отображаются в виде 32-битного значения, называемого DWORD (двойное слово). Обратите внимание, что на этой панели показан адрес 0x008CF8A4 в верхней части стека и что это значение, хранится в ESP (в регистре, указывающем на адрес вершины стека):

переполнение буфера

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

переполнение буфера windows

Навигация по коду


При загрузке, выполнение приложения было приостановлено, о чем свидетельствует слово "Paused" в нижней части экрана.

Нажимаем "Run program" (F9):

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

Мы можем выполнять инструкции по одной, используя команды Debug > Step into или Debug > Step over которые имеют комбинации клавиш F7 и F8 соответственно. Разница между этими двумя командами заключается в том, что Step into будет следовать за потоком выполнения в данном вызове функции, в то время как Step over будет выполнять всю функцию и возвращаться из нее. Так как точка входа в данном случае не совпадает с началом главной функции, наша первая цель - найти, где в памяти находится главная функция. В данном конкретном приложении мы можем поискать в памяти процесса сообщение об ошибке ("ERROR - Vy dolzhny predostavit kak minimum odin argument"), чтобы помочь нам сориентироваться.


Для поиска щелкните правой кнопкой мыши в левом верхнем окне и выберите Search for > All referenced text strings:

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

Дважды щелкнув на этой строке, мы возвращаемся в окно дизассемблирования, внутрь функции main. Мы видим инструкцию, которая выводит сообщение об ошибке.

Нас интересует сам вызов функции strcpy, поэтому мы можем установить точку останова на этой инструкции. Точка останова (breakpoint) - это, по сути, пауза, которую отладчик может установить на любой инструкции программы.


Чтобы установить точку останова на вызове функции strcpy, выделим в окне дизассемблера строку и нажимаем клавишу F2. Далее мы можем продолжить выполнение, выбрав Debug > Run или нажав F9. Почти сразу же выполнение останавливается прямо перед вызовом функции strcpy, где мы установили нашу точку останова:

механизм переполнения буфера

Выполнение приостановилось на команде strcpy. EIP установлен на этот адрес, так как он указывает на следующую инструкцию, которая будет выполнена. В стеке мы находим двенадцать символов "A" из нашей командной строки и адрес 64-байтовой переменной, куда будут скопированы эти символы (dest = XXXXXXXX). Теперь мы можем войти в вызов strcpy (с помощью Debug > Step into). Обратите внимание, что адреса в левом верхнем окне инструкций ассемблера изменились, потому что теперь мы находимся внутри функции strcpy. Об этом свидетельствует выделенный адрес.


Теперь мы можем дважды щелкнуть на адресе назначения strcpy в панели стека, чтобы лучше отслеживать операции записи в память, происходящие по этому адресу.


Это немного изменяет вид панели стека. Теперь мы видим положительные и отрицательные смещения в левой колонке вместо адресов.

Давайте рассмотрим это окно стека более подробно. Во-первых, обратите внимание, что смещение "ноль", теперь выделено и отмечено стрелкой (==>). Это начало буфера, куда в конечном итоге будет скопирована наша входная строка из букв "А". Поскольку мы определили буфер размером 64 байта, наш буфер простирается от смещения 0 до смещения +40.

Обратите внимание, что в этом буфере есть адреса возврата и т.д. Когда придет время скопировать наш массив "A" в буфер, эти остаточные данные будут перезаписаны.


Далее мы можем продолжить работу программы до конца функции strcpy (Debug > Execute till return или Ctrl + F9). Это позволит нам увидеть результат работы функции strcpy.


Функция strcpy скопировала двенадцать символов "A" в стек, и мы явно не выходим за пределы 64-байтового буфера. Теперь, когда функция strcpy завершила свое выполнение и все данные были скопированы в буфер, пришло время вернуть выполнение в main с помощью инструкции RETN. Инструкция RETN "выталкивает" значение на вершине стека в регистр EIP, давая указание процессору выполнить код в этом месте следующим. Нажимаем F7.


Инструкция MOV EAX, 0 является эквивалентом команды return 0 (в нашем исходном коде) и отправляет статус выхода 0 в операционную систему. На этом этапе мы достигли конца главной функции. Следующая инструкция LEAVE копирует содержимое EBP в ESP, тем самым выбрасывая из стека весь кадр и считывает из стека значение регистра EBP для предыдущей процедуры. Инструкция LEAVE - это по сути, две выполненные по очереди инструкции:


mov esp, ebp

pop ebp


Затем ассемблерная инструкция RETN снимает адрес возврата главной функции с вершины стека и выполняет находящийся там код. При этом из вершины стека выталкивается значение в регистр EIP.


Переполнение буфера


Теперь давайте сделаем переполнение буфера. Для этого мы можем снова открыть приложение с помощью File > Open и ввести 80 символов "A" в поле Arguments. После этого установим точку останова на функции strcpy (F2) и продолжим выполнение до возврата (F9).


Если мы продолжим выполнение и перейдем к инструкции RETN, то перезаписанный адрес возврата будет занесен в EIP. В этот момент процессор попытается прочитать следующую инструкцию из 0x41414141. Поскольку это недопустимый адрес в памяти процесса, это приведет к краху приложения:

механизм переполнения буфера

Важно помнить, что регистр EIP используется центральным процессором для направления выполнения кода на уровне ассемблера. Поэтому получение контроля над EIP позволит нам выполнять любой код на ассемблере. Например, шелл-код (shellcode), чтобы сделать Reverse Shell.

Курс по этичному хакингу: cyberden.pw/kurs

Сайт нашей команды: cyberteam.tech/pentest

TG-канал: https://t.me/cyberden_team


hack@cyberden.pw