disclaimer: Данный материал является свободным (приближенным к оригиналу) переводом методического материала PWK, в частности Глава 10. Введение в переполнение буфера. В связи с закрытым форматом распространения данного материала убедительная просьба ознакомившихся с ней не осуществлять свободное распространение содержимого статьи, чтобы не ставить под удар других участников форума. Приятного чтения.
Вступление
В данной главе представлены принципы переполнения буфера, которое является одним из видов уязвимости повреждения памяти. Рассмотрим, как используется память программы, как происходит переполнение буфера, и как это переполнение может быть использовано для управления потоком выполнения приложения. Хорошее понимание условий, делающих эту атаку возможной, крайне важно для разработки эксплоита, чтобы извлечь пользу из данного типа уязвимости.
Введение в Архитектуру x86
Чтобы понять, как происходят повреждения памяти и как их можно использовать для несанкционированного доступа, необходимо обсудить программную память, понять, как работает программное обеспечение на уровне процессора, и отметить несколько основных определений.
Обсуждая эти принципы, будем довольно часто обращаться кСсылка скрыта от гостей, крайне низкоуровневому языку программирования, который очень точно соответствует инструкциям встроенного машинного кода процессора.
Память Программы
Когда исполняется бинарное приложение, оно выделяет память очень специфическим образом в пределах границ памяти, используемых современными компьютерами. На Рисунке 1 показано, как память процесса размещается в Windows между наименьшим (0x00000000) и наибольшим (0x7FFFFFFF) адресами памяти, используемыми приложениями:
Рисунок 1: Анатомия памяти программы в Windows
Хотя на этом рисунке отмечено несколько областей памяти, для наших целей сосредоточимся на стеке.
Стек
Когда поток выполняется, он выполняет код из Программного Образа или из различных
Ссылка скрыта от гостей
. Поток требует кратковременно существующей области данных для функций, локальных переменных и информации об управлении программой, которая известна как
Ссылка скрыта от гостей
. Для облегчения самостоятельного выполнения нескольких потоков, каждый поток в выполняющемся приложении имеет свой собственный стек.Стековая память "рассматривается" процессором как структура Last-In-First-Out (LIFO) (последний вошел, первый вышел). Это, по сути, означает, что при обращении к стеку, элементы, помещенные ("pushed") на вершину стека, удаляются ("popped") первыми. Архитектура x86 реализует специальные ассемблерные инструкции PUSH и POP для добавления или удаления данных из стека соответственно.
Также может понадобиться область для более длительного хранения данных или для хранения динамических данных, которая называетсяСсылка скрыта от гостей. Но так как мы сосредоточены на переполнении стека, не будем обсуждать кучу в этом модуле.
Механизм возврата из функции
Когда код внутри потока вызывает функцию, ему необходимо знать, к какому адресу вернуться после завершения работы функции. Этот "адрес возврата" (вместе с параметрами функции и локальными переменными) хранится в стеке. Этот набор данных ассоциирован с одним вызовом функции и хранится в части стековой памяти, известной как фрейм стека. Пример фрейма стека проиллюстрирован на Рисунке 2.
Рисунок 2: Адрес возврата в стеке
Когда функция завершается, адрес возврата берется из стека и используется для возврата потока выполнения в основную программу или в вызывающую функцию.
Хотя это описывает процесс на высоком уровне, потребуется больше понимать то, как это на самом деле работает на уровне процессора. Для этого надо обсудить
Ссылка скрыта от гостей
.Регистры ЦП
Для эффективного выполнения кода ЦП поддерживает и использует набор из девяти 32-битных регистров (на 32-битной платформе). Регистры - это небольшие, чрезвычайно высокоскоростные хранилища ЦП, в которых можно эффективно читать данные или манипулировать ими. Эти девять регистров, включая условные обозначения для страших и младших битов этих регистров, показаны на Рисунке 3.
Рисунок 3: Регистры ЦП x86
Имена регистров были установлены для 16-битных архитектур и были расширены с появлением 32-битных (x86) платформ. Отсюда и буква "E" (Extended) в аббревиатурах регистров. Каждый регистр может содержать 32-битное значение (допускающее значения от 0 до 0xFFFFFFFF) или может содержать 16-битные или 8-битные значения в соответствующих подрегистрах, как показано в регистре EAX на Рисунке 4.
Рисунок 4: 16-битные и 8-битные подрегистры
Так как целью этого модуля является демонстрация переполнения буфера, не будем углубляться в детали ассемблирования, а рассмотрим лишь основные моменты, которые важны для данной задачи. Для получения детальной информации, или расширения базовых приемов, описанных здесь, обратитесь кСсылка скрыта от гостей.
Регистры Общего Назначения
Несколько регистров, включая EAX, EBX, ECX, EDX, ESI и EDI часто используются в качестве регистров общего назначения для временного хранения данных. Здесь можно много рассказывать (подробнее описано в
Ссылка скрыта от гостей
), но рассмотрим следующие основные регистры:- EAX (аккумулятор): Арифметические и логические инструкции
- EBX (база): Базовый указатель для адресов памяти
- ECX (счетчик): Счетчик циклов, смещений и ротации
- EDX (данные): Умножение, деление и адресация портов ввода/вывода
- ESI (индекс источника): Указатель на данные и источник в операциях копирования строки
- EDI (индекс назначения): Указатель на данные и назначение в операциях копирования строки
ESP - Указатель Стека
Как уже упоминалось ранее, стек используется для хранения данных, указателей и аргументов. Так как стек является динамическим и постоянно изменяется во время выполнения программы, ESP, указатель стека, хранит указатель на него, "отслеживая" последнее обращение к нему в стеке (вершине стека).
Указатель - это ссылка на адрес (или место) в памяти. Когда мы говорим, что регистр "хранит указатель" или "указывает" на адрес, это, по сути, означает, что регистр хранит этот целевой адрес.
EBP - Базовый Указатель
Поскольку стек во время выполнения потока постоянно изменяется, функции может быть сложно найти свой собственный кадр стека, в котором хранятся необходимые аргументы, локальные переменные и адрес возврата. EBP, базовый указатель, решает эту проблему, сохраняя указатель на вершину стека при вызове функции. Обращаясь к EBP, функция во время выполнения может легко ссылаться на информацию из своего собственного кадра стека (через смещения).
EIP - Указатель на Инструкцию
EIP, указатель на инструкцию, является одним из самых важных регистров для наших целей, так как он всегда указывает на следующую выполняемую инструкцию кода. Так как EIP, по сути, направляет поток программы, он является основной целью атакующего при эксплуатации любой уязвимости повреждения памяти, такой как переполнение буфера.
Переполнение буфера
В этой главе будет проанализировано простое уязвимое приложение, которое должным образом не фильтрует данные, вводимые пользователем. Проанализируем исходный код приложения и обнаружим, что, передавая специально подготовленный аргумент в приложение, можно копировать контролируемую строку ввода в буфер стека меньшего размера, по сути, выходя за его границы. Такое переполнение повредит данные в стеке, что в итоге приведет к перезаписи адреса возврата и полному контролю над регистром EIP.
Получение контроля над EIP - это первый шаг в проведении успешного переполнения буфера. В этой части сосредоточимся на получении контроля над EIP, а в следующих объясним, как это использовать для выполнения произвольного кода.
Пример уязвимого кода
Следующий листинг приводит пример очень простого, написанного на С, исходного кода приложения, уязвимого к переполнению буфера.
C:
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
char buffer[64];
if (argc < 2)
{
printf("Error - You must supply at least one argument\n");
return 1;
}
strcpy(buffer, argv[1]);
return 0;
}
Листинг 1 - Уязвимая функция в C
Даже если вы никогда не работали с кодом С раньше, вам должно быть довольно легко понять логику происходящего на листинге выше. Во-первых, необходимо отметить, что в языке C к функции main относятся так же как и к любой другой. Она может принимать аргументы, возвращать значения вызывающей программе и т.д. Единственное отличие заключается в том, что она "вызывается" самой операционной системой при запуске процесса.
В данном случае функция main сначала определяет массив символов с именем buffer, который может вмещать до 64-х знаков. Так как эта переменная определена внутри функции,
Ссылка скрыта от гостей
будет рассматривать ее как
Ссылка скрыта от гостей
и зарезервирует для нее место в стеке (64 байта). В частности, эта область памяти будет зарезервирована внутри кадра стека функции main, когда она будет выполняться во время выполнения программы.Как следует из названия, локальные переменные имеютСсылка скрыта от гостей, что означает, что они доступны только в рамках функции или блока кода, в котором они объявлены. И напротив,Ссылка скрыта от гостейхранятся в программе в секции .data, другой области программы, которая глобально доступна всему коду приложения.
Далее программа переходит к
Ссылка скрыта от гостей
содержимого заданного аргумента командной строки (
Ссылка скрыта от гостей
) в массив символов buffer. Обратите внимание, что язык С не поддерживает строки в качестве типа данных. На низком уровне строка - это последовательность символов, оканчивающаяся нулевым символом ('\0') или, иначе говоря, одномерный массив символов.Наконец, программа завершает свое выполнение и возвращает ноль (стандартный код успешного завершения работы) операционной системе.
Когда вызываем эту программу, передаем ей аргументы командной строки. Функция main обрабатывает эти аргументы при помощи двух параметров, argc и argv, которые представляют собой количество аргументов, переданных программе (передается как целое число), и массив указателей на сами аргументы "строки", соответственно.
Если аргумент, переданный функции main, равен 64 символам или меньше, то программа будет работать так, как ожидается, и завершится нормально. Однако, так как нет проверки длины пользовательского ввода, если он длиннее, скажем, 80-ти байт, часть стека, смежная с целевым буфером, будет перезаписана оставшимися 16-ю символами, выходя за границы массива. Это показано на Рисунке 5.
Рисунок 5: Структура стека до и после копирования
Последствия такого повреждения памяти зависят от нескольких факторов, в том числе от размера переполнения и данных, включенных в это переполнение. Чтобы увидеть, как это работает в исследуемом сценарии, можно использовать аргумент большего размера в приложении и наблюдать за результатами.
Immunity Debugger. Введение.
Для помощи в разработке эксплойтов можно использовать приложение под названием
Ссылка скрыта от гостей
. Отладчик работает как прокси между приложением и процессором, что позволяет остановить поток выполнения в любое время для проверки содержимого регистров, а также пространства памяти процесса. Запуская приложение через отладчик, также можно выполнять инструкции ассемблера по одной, чтобы лучше понять детализированный поток кода. Хотя существует множество отладчиков, будем использовать
Ссылка скрыта от гостей
, который имеет сравнительно простой интерфейс и позволяет использовать скрипты Python для автоматизации задач.Можно попробовать переполнить буфер в уязвимом тестовом приложении и использовать Immunity Debugger, чтобы лучше понять, что именно происходит на каждом этапе выполнения программы.
Чтобы запустить Immunity и выполнить код, запустим его из ярлыка на Рабочем столе и перейдем в File > Open, как показано на Рисунке 6.
Рисунок 6: Immunity Debugger
В диалоговом окне переходим в директорию windows_buffer_overflow и открываем strcpy.exe, который является скомпилированной версией исходного кода, проанализированного в предыдущем разделе. Перед тем, как нажать Open, добавим 12 символов "А" в поле Arguments, как показано на Рисунке 7. Эти 12 символов будут служить аргументом командной строки и в дальнейшем будут использоваться функцией strcpy.
Рисунок 7: Запуск приложения
При запуске отладчика поток выполнения приложения будет приостановлен в
Ссылка скрыта от гостей
. К сожалению, в этом примере точка входа не совпадает с началом функции main. Это не редкость, так как часто точка входа устанавливается компилятором в секцию кода, созданную для подготовки программы к выполнению. Помимо прочего, эта подготовка включает в себя настройку всех аргументов, которые может ожидать main.Перед тем как идти дальше, поближе познакомимся с Immunity и попрактикуемся в навигации к наиболее актуальным функциям. На Рисунке 8 показан основной экран, который разделен на четыре окна или панели.
Рисунок 8: Интерфейс Immunity Debugger
В левом верхнем окне показаны инструкции ассемблера, из которых состоит приложение. Инструкция, выделенная синим (SUB ESP,0C), это инструкция, которая будет выполнена следующей, и она находится по адресу 0x004014E0 в области памяти процесса:
Рисунок 9: Окно ассемблера в Immunity Debugger
Верхнее правое окно (Рисунок 10) содержит все регистры, включая те два, которые нас больше всего интересуют: *ESP* и *EIP*. Так как по определению EIP указывает на следующую выполняемую инструкцию кода, она имеет значение 0x004014E0, как и выделенная инструкция в окне ассемблера (Рисунок 8):
Рисунок 10: Окно регистров в Immunity Debugger
Правое нижнее окно (Рисунок 11) показывает стек и его содержимое. Такое представление содержит четыре столбца: адрес памяти; шестнадцатеричные данные, расположенные по этому адресу; ASCII представление данных; динамический комментарий, который предоставляет дополнительную информацию, относящуюся к конкретной записи стека, когда она доступна. Сами данные (вторая колонка) отображаются в виде 32-битного значения, называемого DWORD, изображенного в виде четырех шестнадцатеричных байтов. Обратите внимание, что эта панель показывает адрес 0x0065FF84 на вершине стека, и что это и есть значение, которое хранится в ESP в окне регистров (Рисунок 10):
Рисунок 11: Окно стека в Immunity Debugger
В последнем окне внизу слева отображается содержимое памяти по любому адресу. Как и окно стека, оно показывает три столбца, включая адрес памяти, шестнадцатеричное и ASCII представление данных. Как следует из названия, это окно может помочь в поиске или анализе конкретных значений в области памяти, а также может показать данные в различных форматах, если щелкнуть правой кнопкой мыши по содержимому окна для доступа к контекстному меню:
Рисунок 12: Окно данных в Immunity Debugger
Исследование кода
Исследование кода
Теперь пришло время рассмотреть код дизассемблированной программы в отладчике. После загрузки выполнение приложения было приостановлено, о чем свидетельствует надпись "Paused" в правом нижнем углу отладчика. Как уже упоминалось в предыдущей части, отладчик автоматически останавливает программу в точке входа.
Теперь можно выполнять инструкции по одной, используя команды Debug > Step into или Debug > Step over, для которых назначены горячие клавиши F7 и F8 соответственно. Разница между ними в том, что Step into будет следовать потоку выполнения внутри вызова функции, тогда как Step over выполнит всю функцию и вернется из нее.
Так как в данном случае точка входа не соответствует началу функции main, первой целью будет определить, где функция main расположена в памяти. В данном конкретном приложении можно искать в памяти процесса сообщение об ошибке, выделенное ниже, чтобы соориентироваться:
C:
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
char buffer[64];
if (argc < 2)
{
printf("Error - You must supply at least one argument\n");
return 1;
}
strcpy(buffer, argv[1]);
return 0;
}
Листинг 2- Сообщение об ошибке может помочь найти функцию main
Для поиска, нажимаем правой кнопкой мыши в окне дизассемблера и выбираем Search for > All referenced text strings:
Рисунок 13: Поиск строк в Immunity Debugger
В результате получим окно (Рисунок 14), которое показывает строку, которую ищем.
Рисунок 14: Сообщение об ошибке найдено, и можно отследить местоположение функции main
Дважды щелкнув на этой строке, возвращаемся в окно дизассемблера, но на этот раз внутрь функции main. На Рисунке 15 узнаем инструкции, отображающие строку сообщения об ошибке, а также вызов функции strcpy.
Рисунок 15: Функция main успешно найдена
Интересен вызов самой функции strcpy, для этого поставим
Ссылка скрыта от гостей
на этой инструкции. Точка останова - это, по сути, запланированная пауза, которую отладчик может установить на любую инструкцию программы.Рисунок 16: Установка точки останова на вызов функции strcpy
Для установки точки останова на вызов функции strcpy, выделяем строку в окне дизассемблера с адресом 0x004015AA и нажимаем F2. После этого точка останова будет соответствовать инструкции со светло-голубой подсветкой, как показано на Рисунке 16.
Далее можно продолжить поток выполнения, выбрав Debug > Run или нажав F9. Практически сразу выполнение опять останавливается непосредственно перед вызовом функции strcpy, где была установлена точку останова (адрес 0x004015AA).
Рисунок 17: Выполнение strcpy
Как показано на Рисунке 17, выполнение остановилось на команде strcpy (адрес 0x004015AA). EIP также имеет этот адрес, так как он указывает на следующую выполняемую инструкцию. В окне стека видим 12 символов "А" из командной строки (src = "AAAAAAAAAAAA") и адрес 64-байтной переменной buffer, в которую будут скопированы эти символы (dest = 0065FE70).
Теперь можно перейти к вызову strcpy (с помощью Debug > Step into или F7). Обратите внимание, что адреса в верхнем левом окне ассемблерных инструкций изменились, потому что теперь находимся внутри функции strcpy. Об этом свидетельствует выделенный адрес (0x76485E90), показанный на Рисунке 18:
Рисунок 18: Структура стека до выполнения strcpy
Теперь можно дважды нажать на адрес возврата strcpy (0x0065FE70) в панели стека, чтобы лучше отслеживать операции записи, происходящие по этому адресу. Этот адрес отмечен красной стрелкой на Рисунке 19:
Рисунок 19: Отслеживание операций записи в память
Как видно на Рисунке 20, это немного изменяет вид панели стека. Теперь видно относительные (положительные и отрицательные) смещения в левом столбце вместо реальных адресов:
Рисунок 20: Структура стека со смещениями до выполнения strcpy
Взглянем поближе на это окно стека. Во-первых, обратите внимание, что нулевым смещением, которое теперь выделено цветом и отмечено стрелкой (==>), является адрес 0x0065FE70. Это начало буфера назначения, куда в итоге будет скопирована строка из символов "А". Так как в данной программе был определен 64-байтовый буфер (buffer[64]), то он расположен от смещения 0 до смещения +40, включая нулевое окончание массива символов.
Обратите внимание, что в этом буфере есть то, что выглядит как адреса возврата (смещения +С, +1С и +2С), а также и другие странности. Так как не был проинициализирован и не был очищен буфер, когда его создавали, это пространство заполняют остаточные данные, которые отладчик пытается интерпретировать. Когда придет время скопировать массив символов "А" в буфер, эта остаточная информация будет перезаписана.
Далее обратите внимание на адрес возврата 0x004015AF на вершине стека. К этому адрессу вернемся после завершения работы strcpy, а также он соотносится с инструкцией MOV EAX,0, показанной на Рисунке 17. Эта инструкция следует сразу за CALL <JMP.&msvcrt.strcpy>, инструкцией, которая привела сюда и автоматически поместила этот адрес в стек.
Теперь можно продолжить выполнение до конца функции strcpy с помощью Debug > Execute till return или Ctrl+F9. Это позволит увидеть результат ее выполнения:
Рисунок 21: Структура стека со смещениями после выполнения strcpy
На Рисунке 21 strcpy скопировала 12 символов "А" в стек (в буфер), и мы явно находимся в пределах 64-байтового буфера, которые были заданы размером локальной переменной buffer. Перед тем, как продолжить, запомните адрес (004013E3), расположенный со смещением 0x4С (Рисунок 21) от переменной buffer. Это адрес возврата функции main, адрес инструкции, к которой вернемся после завершения выполнения функции main. Сейчас увидим, как это происходит.
Теперь, когда функция strcpy завершила свое выполнение и все данные были скопированы в буфер назначения, пришло время вернуть управление функции main с помощью ассемблерной инструкции RETN, как показано на Рисунке 22.
Рисунок 22: Возврат из strcpy в main
Эта инструкция RETN "выталкивает" значение на вершине стека (0x004015AF, относительное смещение -0x14 на Рисунке 22) в регистр EIP, поручая процессору выполнить код по этому адресу следующим.
Если выполним одну эту инструкцию RETN (с помощью Debug > Step into или F7), то вернемся обратно в функцию main к адресу 0x004015AF, как и ожидалось, так как это следующий адрес после вызова strcpy.
Рисунок 23: Возврат из strcpy в main
Следующая инструкция, MOV EAX,0, эквивалентна команде return 0; в исходном коде и посылает операционной системе код завершения 0.
На данный момент достигли
Ссылка скрыта от гостей
функции main. Здесь функция main просто вернет управление самой первой части кода, созданной компилятором, которая изначально вызвала саму функцию main.Следующая инструкция
Ссылка скрыта от гостей
, помещает адрес возврата со смещением 0x4C (от начала локальной переменной buffer) на вершину стека. Затем инструкция RETN (Рисунок 24) выталкивает адрес возврата функции main с вершины стека и выполняет код, который там находится.Рисунок 24: Возврат из main в родительскую функцию
Необходимо ненадолго остановиться на этом моменте, чтобы понять общую картину. Когда strcpy скопировала сроку (12 байт) из аргумента командной строки в стек в переменную buffer, она начала записывать с адреса 0x0065FE70 и далее. В этом случае в коде С нет никаких проверок на размер копируемых данных, поэтому если передадим на вход нашей программе более длинную строку, то в итоге данных, записанных в стек будет достаточно, чтобы перезаписать адрес возврата родительской функции main, который расположен со смещением 0x4C от переменной buffer. Это означает, что если адрес возврата будет перезаписан правильно, то указатель инструкций будет под нашим контролем, так как он будет содержать некоторые данные из заданного аргумента, когда функция main вернет управление родительской функции.
Переполнение буфера
В предыдущем примере были записаны только 12 байт из доступных 64-х, поэтому программа завершилась без ошибок, как и ожидалось. Однако, смещение между адресом переменной buffer (0x0065FE70) и адресом возврата функции main составляет 76 (0x4C в шестнадцатеричном исчислении) байт, поэтому если передадим 80 символов "А" в качестве аргумента, они все будут скопированы в стек и записаны за границы buffer в адрес возврата, как показано на Рисунке 25.
Рисунок 25: Иллюстрация переполнения буфера
Для этого можно заново открыть приложение с помощью File > Open и ввести 80 символов "А" в поле Arguments. После установки точки останова на функцию strcpy и продолжив выполнение, получаем структуру стека, показанную на Рисунке 26.
Рисунок 26: Адрес возврата родительской функции main перезаписан в стеке
Если продолжим выполнение и перейдем к инструкции RETN в функции main, то перезаписанный адрес возврата будет вытолкнут в EIP.
Рисунок 27: Функция main возвращается в некорректный адрес 0x41414141
В этот момент процессор пытается прочитать следующую инструкцию из 0x41414141. Так как это некорректный адрес в области памяти процесса, то срабатывает ошибка нарушения доступа, вызывая аварийное завершение работы приложения.
Рисунок 28: Перезапись EIP
Опять же, важно помнить, что регистр EIP используется процессором на уровне ассемблера для направления выполнения кода. Следовательно, получение контроля над EIP позволит выполнить любой ассемблерный код и, в конечном счете, шеллкод, с целью получить обратный шелл в контексте уязвимого приложения. Проследим за этим до конца в следующих модулях.
Заключение
В этом модуле были представлены принципы переполнения буфера, являющегося одним из видов уязвимостей повреждения памяти. Были рассмотрены, как используется память программы, как происходит переполнение буфера, и как оно может использоваться для управления потоком выполнения приложения. Это обеспечило хорошее понимание условий, делающих эту атаку возможной, и поможет в следующих модулях разрабатывать эксплойты, использующие данный тип уязвимости.
Последнее редактирование: