• 🚨 29 мая стартует курс «Пентест Active Directory: от теории к практике» от Академии Кодебай

    🔍 Изучите реальные техники атак на инфраструктуру Active Directory: от первоначального доступа до полной компрометации.
    🛠️ Освойте инструменты, такие как BloodHound, Mimikatz, CrackMapExec и другие.
    🧪 Пройдите практические лабораторные работы, имитирующие реальные сценарии атак.
    🧠 Получите знания, которые помогут вам стать востребованным специалистом в области информационной безопасности.

    После старта курса запись открыта еще 10 дней Подробнее о курсе ...

  • Познакомьтесь с пентестом веб-приложений на практике в нашем новом бесплатном курсе

    «Анализ защищенности веб-приложений»

    🔥 Записаться бесплатно!

Статья PWK-(10) Введение в переполнение буфера

disclaimer: Данный материал является свободным (приближенным к оригиналу) переводом методического материала PWK, в частности Глава 10. Введение в переполнение буфера. В связи с закрытым форматом распространения данного материала убедительная просьба ознакомившихся с ней не осуществлять свободное распространение содержимого статьи, чтобы не ставить под удар других участников форума. Приятного чтения.

Вступление

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

Введение в Архитектуру x86

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

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

Память Программы

Когда исполняется бинарное приложение, оно выделяет память очень специфическим образом в пределах границ памяти, используемых современными компьютерами. На Рисунке 1 показано, как память процесса размещается в Windows между наименьшим (0x00000000) и наибольшим (0x7FFFFFFF) адресами памяти, используемыми приложениями:

program_memory_1.png

Рисунок 1: Анатомия памяти программы в Windows

Хотя на этом рисунке отмечено несколько областей памяти, для наших целей сосредоточимся на стеке.

Стек

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

Стековая память "рассматривается" процессором как структура Last-In-First-Out (LIFO) (последний вошел, первый вышел). Это, по сути, означает, что при обращении к стеку, элементы, помещенные ("pushed") на вершину стека, удаляются ("popped") первыми. Архитектура x86 реализует специальные ассемблерные инструкции PUSH и POP для добавления или удаления данных из стека соответственно.

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

Механизм возврата из функции

Когда код внутри потока вызывает функцию, ему необходимо знать, к какому адресу вернуться после завершения работы функции. Этот "адрес возврата" (вместе с параметрами функции и локальными переменными) хранится в стеке. Этот набор данных ассоциирован с одним вызовом функции и хранится в части стековой памяти, известной как фрейм стека. Пример фрейма стека проиллюстрирован на Рисунке 2.

function_return_mechanics_1.png

Рисунок 2: Адрес возврата в стеке

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

Хотя это описывает процесс на высоком уровне, потребуется больше понимать то, как это на самом деле работает на уровне процессора. Для этого надо обсудить .

Регистры ЦП

Для эффективного выполнения кода ЦП поддерживает и использует набор из девяти 32-битных регистров (на 32-битной платформе). Регистры - это небольшие, чрезвычайно высокоскоростные хранилища ЦП, в которых можно эффективно читать данные или манипулировать ими. Эти девять регистров, включая условные обозначения для страших и младших битов этих регистров, показаны на Рисунке 3.

cpu_registers_1.png

Рисунок 3: Регистры ЦП x86

Имена регистров были установлены для 16-битных архитектур и были расширены с появлением 32-битных (x86) платформ. Отсюда и буква "E" (Extended) в аббревиатурах регистров. Каждый регистр может содержать 32-битное значение (допускающее значения от 0 до 0xFFFFFFFF) или может содержать 16-битные или 8-битные значения в соответствующих подрегистрах, как показано в регистре EAX на Рисунке 4.

cpu_registers_2.png

Рисунок 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.

sample_vulnerable_code_1.png

Рисунок 5: Структура стека до и после копирования​

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

Immunity Debugger. Введение.


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

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

Чтобы запустить Immunity и выполнить код, запустим его из ярлыка на Рабочем столе и перейдем в File > Open, как показано на Рисунке 6.

introducing_the_immunity_debugger_1.png

Рисунок 6: Immunity Debugger

В диалоговом окне переходим в директорию windows_buffer_overflow и открываем strcpy.exe, который является скомпилированной версией исходного кода, проанализированного в предыдущем разделе. Перед тем, как нажать Open, добавим 12 символов "А" в поле Arguments, как показано на Рисунке 7. Эти 12 символов будут служить аргументом командной строки и в дальнейшем будут использоваться функцией strcpy.

introducing_the_immunity_debugger_2.png

Рисунок 7: Запуск приложения

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

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

introducing_the_immunity_debugger_3.png

Рисунок 8: Интерфейс Immunity Debugger

В левом верхнем окне показаны инструкции ассемблера, из которых состоит приложение. Инструкция, выделенная синим (SUB ESP,0C), это инструкция, которая будет выполнена следующей, и она находится по адресу 0x004014E0 в области памяти процесса:

introducing_the_immunity_debugger_4.png

Рисунок 9: Окно ассемблера в Immunity Debugger

Верхнее правое окно (Рисунок 10) содержит все регистры, включая те два, которые нас больше всего интересуют: *ESP* и *EIP*. Так как по определению EIP указывает на следующую выполняемую инструкцию кода, она имеет значение 0x004014E0, как и выделенная инструкция в окне ассемблера (Рисунок 8):

introducing_the_immunity_debugger_5.png

Рисунок 10: Окно регистров в Immunity Debugger

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

introducing_the_immunity_debugger_6.png

Рисунок 11: Окно стека в Immunity Debugger

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

introducing_the_immunity_debugger_7.png

Рисунок 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:

navigatin_code_1.png

Рисунок 13: Поиск строк в Immunity Debugger

В результате получим окно (Рисунок 14), которое показывает строку, которую ищем.

navigatin_code_2.png

Рисунок 14: Сообщение об ошибке найдено, и можно отследить местоположение функции main

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

navigatin_code_3.png

Рисунок 15: Функция main успешно найдена

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

navigatin_code_4.png

Рисунок 16: Установка точки останова на вызов функции strcpy

Для установки точки останова на вызов функции strcpy, выделяем строку в окне дизассемблера с адресом 0x004015AA и нажимаем F2. После этого точка останова будет соответствовать инструкции со светло-голубой подсветкой, как показано на Рисунке 16.

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

navigatin_code_5.png

Рисунок 17: Выполнение strcpy

Как показано на Рисунке 17, выполнение остановилось на команде strcpy (адрес 0x004015AA). EIP также имеет этот адрес, так как он указывает на следующую выполняемую инструкцию. В окне стека видим 12 символов "А" из командной строки (src = "AAAAAAAAAAAA") и адрес 64-байтной переменной buffer, в которую будут скопированы эти символы (dest = 0065FE70).

Теперь можно перейти к вызову strcpy (с помощью Debug > Step into или F7). Обратите внимание, что адреса в верхнем левом окне ассемблерных инструкций изменились, потому что теперь находимся внутри функции strcpy. Об этом свидетельствует выделенный адрес (0x76485E90), показанный на Рисунке 18:

navigatin_code_6.png

Рисунок 18: Структура стека до выполнения strcpy

Теперь можно дважды нажать на адрес возврата strcpy (0x0065FE70) в панели стека, чтобы лучше отслеживать операции записи, происходящие по этому адресу. Этот адрес отмечен красной стрелкой на Рисунке 19:

navigatin_code_7.png

Рисунок 19: Отслеживание операций записи в память

Как видно на Рисунке 20, это немного изменяет вид панели стека. Теперь видно относительные (положительные и отрицательные) смещения в левом столбце вместо реальных адресов:

navigatin_code_8.png

Рисунок 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. Это позволит увидеть результат ее выполнения:

navigatin_code_9.png

Рисунок 21: Структура стека со смещениями после выполнения strcpy

На Рисунке 21 strcpy скопировала 12 символов "А" в стек (в буфер), и мы явно находимся в пределах 64-байтового буфера, которые были заданы размером локальной переменной buffer. Перед тем, как продолжить, запомните адрес (004013E3), расположенный со смещением 0x4С (Рисунок 21) от переменной buffer. Это адрес возврата функции main, адрес инструкции, к которой вернемся после завершения выполнения функции main. Сейчас увидим, как это происходит.

Теперь, когда функция strcpy завершила свое выполнение и все данные были скопированы в буфер назначения, пришло время вернуть управление функции main с помощью ассемблерной инструкции RETN, как показано на Рисунке 22.

navigatin_code_10.png

Рисунок 22: Возврат из strcpy в main

Эта инструкция RETN "выталкивает" значение на вершине стека (0x004015AF, относительное смещение -0x14 на Рисунке 22) в регистр EIP, поручая процессору выполнить код по этому адресу следующим.

Если выполним одну эту инструкцию RETN (с помощью Debug > Step into или F7), то вернемся обратно в функцию main к адресу 0x004015AF, как и ожидалось, так как это следующий адрес после вызова strcpy.

navigatin_code_11.png

Рисунок 23: Возврат из strcpy в main

Следующая инструкция, MOV EAX,0, эквивалентна команде return 0; в исходном коде и посылает операционной системе код завершения 0.

На данный момент достигли функции main. Здесь функция main просто вернет управление самой первой части кода, созданной компилятором, которая изначально вызвала саму функцию main.

Следующая инструкция , помещает адрес возврата со смещением 0x4C (от начала локальной переменной buffer) на вершину стека. Затем инструкция RETN (Рисунок 24) выталкивает адрес возврата функции main с вершины стека и выполняет код, который там находится.

navigatin_code_12.png

Рисунок 24: Возврат из main в родительскую функцию

Необходимо ненадолго остановиться на этом моменте, чтобы понять общую картину. Когда strcpy скопировала сроку (12 байт) из аргумента командной строки в стек в переменную buffer, она начала записывать с адреса 0x0065FE70 и далее. В этом случае в коде С нет никаких проверок на размер копируемых данных, поэтому если передадим на вход нашей программе более длинную строку, то в итоге данных, записанных в стек будет достаточно, чтобы перезаписать адрес возврата родительской функции main, который расположен со смещением 0x4C от переменной buffer. Это означает, что если адрес возврата будет перезаписан правильно, то указатель инструкций будет под нашим контролем, так как он будет содержать некоторые данные из заданного аргумента, когда функция main вернет управление родительской функции.

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

В предыдущем примере были записаны только 12 байт из доступных 64-х, поэтому программа завершилась без ошибок, как и ожидалось. Однако, смещение между адресом переменной buffer (0x0065FE70) и адресом возврата функции main составляет 76 (0x4C в шестнадцатеричном исчислении) байт, поэтому если передадим 80 символов "А" в качестве аргумента, они все будут скопированы в стек и записаны за границы buffer в адрес возврата, как показано на Рисунке 25.

overflowing_the_buffer_1.png

Рисунок 25: Иллюстрация переполнения буфера

Для этого можно заново открыть приложение с помощью File > Open и ввести 80 символов "А" в поле Arguments. После установки точки останова на функцию strcpy и продолжив выполнение, получаем структуру стека, показанную на Рисунке 26.

overflowing_the_buffer_2.png

Рисунок 26: Адрес возврата родительской функции main перезаписан в стеке

Если продолжим выполнение и перейдем к инструкции RETN в функции main, то перезаписанный адрес возврата будет вытолкнут в EIP.

overflowing_the_buffer_3.png

Рисунок 27: Функция main возвращается в некорректный адрес 0x41414141

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

overflowing_the_buffer_4.png

Рисунок 28: Перезапись EIP

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

Заключение

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

Обучение наступательной кибербезопасности в игровой форме. Начать игру!

Курс AD