Привет, дорогие друзья Данная статья посвящена теме эксплуатации бинарных уязвимостей, описывает некоторые базовые понятия, а так-же различные техники эксплуатации и поиск таких уязвимостей. Приятного чтения! Основы Стек Стек (c английского: stack, в контексте нашей статьи) - это структура, которая хранит различные данные, будь то значения регистров, адреса возврата и прочее. Подчиняется эта структура правилу LIFO (Last In First Out), что дословно переводится как "Последний зашёл - первым выйдет", пример: 1. Мы засунули в стек число 0xA (первый зашёл) 2. Затем мы поместили туда число 0xF (последний зашёл) После чего мы обратились к стеку, чтобы вытащить оттуда значение, и получили 0xF (последний зашёл => первым вышел). По своей механике стек можно сравнить со стопкой из тарелок: - Одной рукой мы не можем взять тарелку из середины или снизу, потому что удобнее всего будет взять верхнюю тарелку. Регистр ESP хранит адрес вершины стека. Регистр EBP хранит базовый адрес текущего кадра стека (то есть точку, по отрицательному или положительному смещению от которой мы попадаем на локальные переменные, адреса возврата, аргументы функций и тд). Наглядная демонстрация (адрес => значение): // выше всех идут локальные переменные, это следует запомнить [ESP] или [EBP-0x8] => 0xF < последнее число в стеке [ESP+0x4] или [EBP-0x4] => 0xA <- первое число в стеке [ESP+0x8] или [EBP] => SAVED EBP <- базовый адрес текущего кадра стека [ESP+0x12] или [EBP+0x4] => RETURN <- адрес возврата // тут аргументы функции (если бы они были) - ARG 1, ARG 2 // допустим [EBP+0x8] хранит число 0x100 - это первый аргумент функции, то есть ARG 1 То есть, данные помещаются в стек как-бы сверху вниз (из-за адресации, от меньшего адреса к большему): Регистры Регистры - это участки памяти непосредственно на самом процессоре, которые могут хранить 8, 16, 32, и 64 битные числа. Небольшая оговорка. В архитектуре x86 основными являются 32 битные регистры, EAX, EBX, ECX, EDX, ESP, EBP и тд. В архитектуре x64 помимо упомянутых, так-же существуют 64 битные регистры (здесь основными будут уже они), их отличие в том, что называются они RAX, RBX, RCX (по аналогии с 32 битными, только первая буква R) и хранят по 64 бита каждый. Следующие регистры в архитектуре х64 будут взаимосвязаны: EAX <-> RAX EBX <-> RBX ECX <-> RCX При изменении значения регистра EAX, будут изменены 32 бита (половина) значения регистра RAX: Инструкции / Опкоды Опкоды - это инструкции для процессора, благодаря которым он понимает, какую операцию необходимо совершить - то есть числа. Процессор буквально работает с байтами (машинным кодом), поэтому для удобства были придуманы инструкции - мнемонические слова, сопоставляемые с опкодами. пример: инструкция nop означает "ничего не делать", её опкод 0x90. инструкция add берёт первый операнд, и прибавляет к нему второй операнд, пример: add eax, 15. инструкция pop берёт значение из стека, и помещает в указанный регистр, пример: pop eax. инструкция push помещает значение указанного регистра в стек, пример: push eax. инструкция ret выполняет переход на адрес возврата. Соглашения о вызовах Соглашение о вызове - это способ передачи параметров в функцию. Простая функция в ассемблере выглядит примерно так: pop eax add eax, 10 push eax ret Для того, чтобы вызвать эту функцию, необходимо чтобы в стеке лежал один параметр. Пройдёмся по инструкциям: - инструкция pop eax поместит значение со стека в регистр eax. - инструкция add eax, 10 увеличит значение в регистре eax на 10. - инструкция push eax запишет значение регистра eax в стек. - инструкция ret совершит переход на адрес возврата, т.е туда, откуда была вызвана эта функция (на следующую после вызова инструкцию). Таким образом, если мы передадим в стек число 20, после выполнения функции в стеке будет лежать число 30. Описанный мною способ передачи параметров через стек называется cdecl (происходит от c-declaration), это одно из соглашений о вызове. thiscall (справедлив для C++) - подразумевает, что первый аргумент функции это указатель на текущий экземпляр (объект) класса, пример: void animal::print_age() { std::cout << this->age << std::endl; } cpp void animal::print_age() { std::cout << this->age << std::endl; } на самом деле ООП не существует, и под капотом вы увидите обычные функции без классов, нечто вроде: void $animal@print_age$26278(uintptr_t this) { // где 0x10 - это offsetof(animal, name), то есть смещение от начала объекта (структура animal) до адреса, с которого начинается определенное поле, в нашем случае name std::cout << *(uint32_t*)(this+0x10) << std::endl; } cpp void $animal@print_age$26278(uintptr_t this) { // где 0x10 - это offsetof(animal, name), то есть смещение от начала объекта (структура animal) до адреса, с которого начинается определенное поле, в нашем случае name std::cout << *(uint32_t*)(this+0x10) << std::endl; } в данном случае this это простая числовая переменная, которая хранит адрес объекта класса animal, то есть начало данной структуры. если сместиться на 0x10, мы получим адрес поля age. Global Offset Table (GOT) Глобальная таблица смещений (Global Offset Table) - это секция (область памяти) внутри программы, которая хранит адреса динамически прилинкованных функций. Динамическая линковка нужна для экономии места и избежания дублирования кода внутри программы (по сравнению со статической линковкой). Указатели Сложная для многих тема, но постараюсь объяснить простым языком. Каждая переменная занимает место в памяти: char - 1 байт // хранит значение от -127 до 128, либо от 0 до 255, если unsigned (беззнаковое число, отсчёт начинается с нуля) short - 2 байта // от -32768 до 32767 (возможно любители игры Minecraft узнали это число), либо от 0 до 65535, если unsigned int, long, float - 4 байта // от -2.147.483.648 до 2.147.483.647 size_t - 4 или 8 байт (зависит от разрядности системы) long long, long float, double - 8 байт // для очень больших чисел Создание переменных в C++ выглядит так: int x = 14; // целое число x со значением 14 float PI = 3.14f; // дробное число PI со значением 3.14 cpp int x = 14; // целое число x со значением 14 float PI = 3.14f; // дробное число PI со значением 3.14 Так-же у каждой переменной есть свой адрес - это место в памяти, где она находится. Обратиться к адресу любой переменной можно при помощи оператора &, то есть: std::cout << &x << std::endl; // в консоль будет выведен адрес переменной x. А создание указателей в C++ выглядит следующим образом: int* x_ptr = &x; // указатель x_ptr указывает на тип данных int (4 байта в памяти), и хранит адрес переменной x. cpp int* x_ptr = &x; // указатель x_ptr указывает на тип данных int (4 байта в памяти), и хранит адрес переменной x. Стоит заметить, что раз указатель хранит адрес, он является таким-же целым числом, и следовательно - занимает какое-то место в памяти (адреса не могут храниться в воздухе). Для этого существуют типы данных size_t, uintptr_t, intptr_t и тд. На 32 битных системах указатели занимают 4 байта (потому что максимальное кол-во ОЗУ - 8 гбайт, и банально не нужно много места, чтобы записать число - адрес в памяти, т.к она сильно ограничена): sizeof(void*), sizeof(int*) и sizeof(size_t) будет равно sizeof(uintptr_t) и тд, все они будут равны 4 байтам. На 64 битных системах указатели занимают по 8 байт соответственно (ибо лимит по памяти там гораздо больше, и чтобы обратиться к "далёкой" ячейке, например если у вас 64 гбайт ОЗУ, мы просто не сможем уместить число в 4 байта, банально не хватит размера указателя): sizeof(void*), sizeof(int*), sizeof(size_t) и ему подобных типов данных будет равно 8 байтам Арифметика указателей Арифметика указателей это особые операции, которые можно производить с указателями. p.s Указатели - численные типы данных, значение которых - какой-то адрес, если по адресу 0x100 лежит переменная int x = 15, то указатель int* y = (int*)0x100 (или int* y = &x) будет указывать на x, следовательно обращаясь к указателю оператором разыменования (*) мы получим значение переменной x, то есть: *y == x (будет true). Во первых, указатели можно складывать друг с другом. Во вторых, к указателям можно применять операторы --, ++, складывать с ними другие числа (отнимать, прибавлять единицу). пример: Если мы имеем дело с динамическим массивом (а это тоже указатель), мы можем использовать сложение словно индекс элемента в массиве: int len = 6; int* arr = malloc(sizeof(int) * len); // проходимся по элементам for (int i = 0; i < len; i++) { //arr + 0 (подобно просто arr) указывает на первый элемент массива // arr + 1 - на второй элемент массива // и так далее (пусть 0, 1 и тд будет индексом). // для более глубокого понимания, вот так выглядит альтернативная запис (мы как бы сдвигаемся на "индекс умножить на размер элемента"): // *(arr + X) => *(int*)((size_t)arr + 1 * sizeof(arr[0])) // что в целом будет работать примерно одинаково, и даст такой же результат. // вывод текущего элемента в консоль std::cout << *(arr + i) << std::endl; } cpp int len = 6; int* arr = malloc(sizeof(int) * len); // проходимся по элементам for (int i = 0; i < len; i++) { //arr + 0 (подобно просто arr) указывает на первый элемент массива // arr + 1 - на второй элемент массива // и так далее (пусть 0, 1 и тд будет индексом). // для более глубокого понимания, вот так выглядит альтернативная запис (мы как бы сдвигаемся на "индекс умножить на размер элемента"): // *(arr + X) => *(int*)((size_t)arr + 1 * sizeof(arr[0])) // что в целом будет работать примерно одинаково, и даст такой же результат. // вывод текущего элемента в консоль std::cout << *(arr + i) << std::endl; } Про fastbin и largebin Fastbin - это стратегия управления памятью в glibc malloc (вид чанков), обычно используемая для того, чтобы выделять небольшие области в памяти, чаще всего размером от 16 до 128 байт. Использование fastbin в целом положительно влияет на производительность программы из-за оптимизаций, но при этом допускает атаки, которые можно провернуть с ним (подробнее об этом будет ниже). Largebin - это стратегия управления памятью в glibc malloc (вид чанков), соответственно используемая для больших (large) чанков, которые по размеру превышают 512 кбайт. О переполнении буфера ROP chain & gadgets ROP - Return Oriented Programming - возвратно ориентированное программирование, это одна из техник, позволяющая выполнять код путём перезаписи адреса возврата на стеке. ROP цепочка состоит из ROP гаджетов. ROP гаджеты состоят из набора опкодов, которые завершаются опкодом 0xC3 (инструкция ret). Пример ROP гаджета Картинка из интернета. На ней изображён ROP гаджет, находящийся по адресу 0x000ED7CB, который помещает значение регистра EBX в регистр EAX, затем значение из стека помещается в регистр EBX, после чего следующее значение из стека помещается в регистр ESI. При написании эксплоитов стоит учитывать инструкции POP, после адреса возврата в стеке должен быть любой мусор, которым мы забьём эти регистры. В противном случае, в регистры попадут адреса ROP гаджетов, и наша ROP цепочка отработает не так, как планировалось. Различные ROP гаджеты и их сочетание позволяют делать самые разные вещи, начиная от установки конкретных значений в регистры (через mov и константу, или через push и стек), заканчивая вызовом любых функций. Допустим, где-то в недрах уязвимого приложения мы нашли функцию, которая заканчивается так: // пусть инструкция mov будет расположена по адресу 0x100 mov eax, 1h xor ecx, ecx ret asm // пусть инструкция mov будет расположена по адресу 0x100 mov eax, 1h xor ecx, ecx ret 0x100 - это адрес ROP гаджета, который поместит в регистр eax единицу, в ecx - ноль (xor сам на себя обнулит значение регистру), и выполнит переход на следующий адрес возврата. Если мы перезапишем текущий адрес возврата в стеке числом 0x100, то после выполнения уязвимой функции, нас перебросит на указанный адрес - выполнение будет продолжаться, начиная с адреса 0x100. Далее, в регистр eax будет записано число 1, после чего значение регистра ecx будет обнулено, и мы попадём туда, откуда пришли (как описывал выше), то есть ROP гаджет отработал, выполнение идёт дальше. Адрес возврата можно перезаписать несколько раз "подряд", в таком случае адреса должны следовать друг за другом. Пример (как это выглядит в стеке): 0x100 // первый адрес 0x200 // за ним следует второй адрес, сюда мы попадём после выполнения инструкции ret предыдущего ROP гаджета 0x300 // следующие адреса asm 0x100 // первый адрес 0x200 // за ним следует второй адрес, сюда мы попадём после выполнения инструкции ret предыдущего ROP гаджета 0x300 // следующие адреса Поиск ROP гаджетов ROP гаджеты можно искать руками, а можно использовать специальные утилиты. Две основные из них (примеры использования): ropgadget --binary yourApp ropper --file yourApp Эти команды выведут огромный список из ROP гаджетов, рекомендую сразу перенаправлять вывод в файл, для упрощения последующей работы с ними. Я рекомендую использовать обе утилиты вместе, т.к бывали случаи, когда одна из них не могла обнаружить часть гаджетов, которые успешно были найдены другой. Рассмотрим несколько техник, которые помогут приблизиться к выполнению кода. Запись строк в память Чтобы записать строки в память, для начала стоит узнать адрес, куда мы можем записать наше значение. Обнаружив пустую область в программе, записываем её адрес (пусть будет 0x2000 для наглядности), он нам понадобится. Далее наша задача - найти ROP гаджет, которые позволит контролировать значения некоторых регистров, допустим, 0x12345: pop ecx // берём со стека значение и записываем в ecx pop edx // берём со стека значение и записываем в edx ret asm pop ecx // берём со стека значение и записываем в ecx pop edx // берём со стека значение и записываем в edx ret Находим гаджет, который позволит записать данные по указанному нами адресу, пусть это будет, 0x54321: mov dword ptr [edx], ecx // берёт значение из ecx и записывает по адресу edx) ret asm mov dword ptr [edx], ecx // берёт значение из ecx и записывает по адресу edx) ret Допустим, мы хотим записать в память строку "/bin/sh" (привет любителям линукса). Любую строку можно представить в виде чисел, деля её на строки по 4 символа в каждой: "/bin" - 4 байта, которые можно представить в виде числа, пусть это будет число XXXX. "//sh" - здесь я добавил ещё один символ /, чтобы заполнить недостающий четвёртый байт в числе (на путь в строке это никак не повлияет), пусть это будет число YYYY. Записываем в стек адрес возврата, затем значение для ECX, затем значение для EDX, и следующие адреса из ROP цепочки: 0x12345 // первый гаджет, задаём значения регистрам XXXX // значение регистра ECX, т.е первая часть строки 0x2000 // значение регистра EDX, т.е адрес 0x54321 // второй гаджет, записываем строку в память 0x12345 // первый гаджет, задаём значения для второй половины строки YYYY // значение ECX, вторая часть строки 0x2004 // значение EDX, адрес 0x54321 // второй гаджет, дозаписываем вторую часть строки После выполнения данной ROP цепочки мы увидим в памяти нечто подобное: 0x2000 "/" 0x2001 "b" 0x2002 "i" 0x2003 "n" 0x2004 "/" 0x2005 "/" 0x2006 "s" 0x2007 "h" Вызов функций Вызывать функции мы будем на примере линукса. В линуксе есть замечательная функция execve, которая идеально подходит для раскручивания нашей уязвимости и последующего выполнения кода. Плюс этой функции заключается в том, что вызвать её можно через syscall (системный вызов), то есть достаточно просто указать номер функции (для execve это 11) в регистр EAX, затем передать адрес строки ("/bin//sh") в регистр EBX (то есть значение 0x2000, если ориентироваться на прошлый спойлер), после чего заполнить регистры ECX и EDX нулями (это остальные параметры функции. нули - потому что их мы не используем, argv, envp). Номера функций для системного вызова можно посмотреть в заголовочном файле asm/unistd_32.h, если кому интересно), они представлены в виде макросов. Подготовив параметры для вызова функции, можно вызвать её. Делается это при помощи прерывания: int 80h После выполнения данной инструкции будет вызвана оболочка bash, чего более чем достаточно для того, чтобы доказать факт наличия уязвимости максимально наглядно (Proof-of-Concept). Переполнение буфера Переполнение стека (с английского: Stack Overflow) - Явление, при котором процессор записывает данные туда - куда не нужно (по вине разработчика программы), это вызывает неопределённое поведение в программе, что может привести к зависанию, вылету программы или даже к выполнению кода, контролируемого хакером. Различают две основные разновидности этой уязвимости, от них зависит принцип дальнейшей эксплуатации. Stack-based buffer overflow Такая уязвимость возникает, когда мы переполняем стек. Пример уязвимого кода: int main(int argc, char* argv[]) { const char* str = "xxxxxxxx"; char buf[4]; // здесь произойдёт переполнение буфера buf strcpy(buf, str); } cpp int main(int argc, char* argv[]) { const char* str = "xxxxxxxx"; char buf[4]; // здесь произойдёт переполнение буфера buf strcpy(buf, str); } Главные задачи при эксплуатации переполнения стека - установить нужное смещение (забить стек и дойти до адреса возврата), найти необходимые для нашего эксплоита ROP гаджеты, составить валидную ROP цепочку и проэксплуатировать уязвимость при помощи составленного пейлоада. Heap-based buffer overflow Такая уязвимость возникает, когда мы переполняем кучу, т.е область с динамически выделенными данными, например через malloc. В целом, эксплуатация чем-то напоминает переполнение на стеке: - выбраться за пределы буфера; - перезаписать критичные данные; - изменить ход работы программы. Разница лишь в том, что в случае с кучей, эксплуатация переполнения буфера будет куда сложнее, и сильно зависящей от структуры программы, от места где она находится, области видимости, "соседних" динамически выделенных переменных и прочих факторов. Про эксплуатацию переполнения буфера на стеке написано ближе к концу статьи. Поиск и выявление уязвимостей в ПО Patch diffing Patch diffing - метод сравнения двух бинарных файлов разных версий (до и после исправления уязвимости), используется для изучения, как была исправлена уязвимость, и последующего получения информации о том, где она находилась. Такой подход позволяет исследовать конкретную часть уязвимой версии ПО, чтобы разработать эксплоит, и пытаться "доить" тех, кто по какой-то причине забыл обновить его) Довольно популярная техника для написания 1day эксплоитов. Поиск уязвимой и исправленной версий ПО Для наглядности я взял уязвимость CVE-2025-29967. Это уязвимость в протоколе RDP (от Microsoft), подробнее напишу ниже. Официальный бюллетень безопасности Официальный бюллетень Microsoft гласит следующее: тип уязвимости: CWE-122 Heap-based Buffer overflow уровень опасности: критический импакт: Remote Code Execution BASH тип уязвимости: CWE-122 Heap-based Buffer overflow уровень опасности: критический импакт: Remote Code Execution Remote Code Execution - это довольно серьёзный вид уязвимости - Удалённое выполнение кода. думаю, название говорит само за себя. листнём чуть ниже (раздел FAQ), дабы понять, как она эксплуатируется: How could an attacker exploit this vulnerability? In the case of a Remote Desktop connection, an attacker with control of a Remote Desktop Server could trigger a remote code execution (RCE) on the RDP client machine when a victim connects to the attackers server with the vulnerable Remote Desktop Client. понимаем, что уязвима именно клиентская сторона. простым языком: уязвимый RDP клиент (компьютер Васи) подключается к вредоносному серверу, что позволяет выполнить код на стороне клиента. следовательно, для её эксплуатации нам придётся писать скрипт, имитирующий работу RDP сервера. листаем ещё чуть ниже (раздел Security Updates), и видим следующие уязвимые продукты: Windows 11 24H2 сборки с версией до 10.0.26100.4061 / 10.0.26100.3981 Windows 1123H2 сборки с версией до 10.0.22631.5335 Windows 10 22H2 сборки с версией до 10.0.19045.5854 Windows 10 21H2 сборки с версией до 10.0.19044.5854 Remote Desktop client for Windows Desktop версии до 1.2.6228.0 и тд. помимо различных версий Windows, моё внимание привлёк пункт с RDP клиентом: Remote Desktop client for Windows Desktop версии до 1.2.6228.0. в колонке Download есть ссылка на обновление безопасности. открываем её и в пункте Version history and release notes (список версий ПО) видим следующее: картинка из бюллетеня мы вычитали, что патч с исправленной уязвимостью вышел 13 мая 2025 года и имеет версию 1.2.6228. следовательно, версия до него (на скриншоте 1.2.6227, вышедшая 22 апреля 2025 года) будет уязвимой. Поиск исправленной версии (после патча) на этой же странице приложена ссылка на версию с патчем (1.2.6228): https://res.cdn.office.net/remote-desktop-windows-client/ff46c1a3-9b23-4752-a79c-d13a309ca9d3/RemoteDesktop_1.2.6228.0_x64.msi скачиваем её (ссылка на virustotal) Поиск уязвимой версии (до патча) Здесь пришлось применить методы OSINT (разведка по открытым источникам). Обратим внимание на название файла, который мы имеем: RemoteDesktop_1.2.6228.0_x64.msi Попробуем поменять версию на другую, теоретически уязвимая версия должна называться так: RemoteDesktop_1.2.6227.0_x64.msi Теперь гуглим то что получилось, и получаем следующие результаты: гугл На нескольких сайтах есть checksum файла (уникальный хеш), идём на virustotal, чтобы узнать побольше информации о нём (подставляем в ссылку на virustotal этот хеш): https://www.virustotal.com/gui/file/9c8cf57dba0725c594c6ee15cf9670ea507612a3e1036fc584f5eb365d8acf34 Здесь видим, что файл с таким названием действительно существует, а его размер - 32.87 мегабайт, первое сканирование произошло 23 апреля (2025-04-23 19:02:36 UTC), что совпадает с датой релиза данной версии. Далее, на другом сайте из списка я обнаружил ссылку на скачивание уязвимой версии: скриншот Нас интересует версия x64 (не смотря на то, что 32 битные бинари более просты для анализа). скачиваем её по прямой ссылке: https://res.cdn.office.net/remote-d...4fc7729a7ee7/RemoteDesktop_1.2.6227.0_x64.msi Домен совпадает с тем, откуда мы устанавливали версию 6228, поэтому беспокоиться не о чем, на всякий случай снова прикладываю ссылку на virustotal. Хеш данного файла совпал с хешом, который был ранее обнаружен выше. И так, на данный момент мы имеем два MSI файла: RemoteDesktop_1.2.6228.0_x64.msi - после патча RemoteDesktop_1.2.6227.0_x64.msi - до патча MSI - это своего рода архив, который содержит само ПО, определённые ресурсы, значения регистров Windows и тд. Точное определение из гугла: MSI-файл представляет собой базу данных, в которой хранятся все необходимые данные для процесса установки, такие как файлы, ярлыки, записи в реестре и т. д. Для адекватного анализа стоит разобрать его, и вытащить оттуда непосредственно бинарники. Разбираем MSI на запчасти Копируем .msi файлы в общую папку, у меня это: C:\Users\New\Desktop\CVE-2025-29967 Создаём внутри ещё две папки: C:\Users\New\Desktop\CVE-2025-29967\1.2.6227 C:\Users\New\Desktop\CVE-2025-29967\1.2.6228 В них мы сохраним результат распаковки .msi файлов (разных версий соответственно). Манипуляции с MSI файлами позволяет проводить команда msiexec. Открываем терминал, вводим msiexec --help и получаем помощь по её использованию: Вывод "msiexec --help" (нудятина) Установщик Windows. Версия XXXXX msiexec /Option <обязательный параметр> [необязательный параметр] Параметры установки </package | /i> <Product.msi> Установка или настройка продукта /a <Product.msi> Административная установка - установка продукта в сеть /j<u|m> <Product.msi> [/t <список преобразований>] [/g <код языка>] Объявление о продукте: "m" - всем пользователям; "u" - текущему пользователю </uninstall | /x> <Product.msi | Код_продукта> Удаление продукта Параметры отображения /quiet Тихий режим, без взаимодействия с пользователем /passive Автоматический режим - только указатель хода выполнения /q[n|b|r|f] Выбор уровня интерфейса пользователя n - Без интерфейса b - Основной интерфейс r - Сокращенный интерфейс f - Полный интерфейс (по умолчанию) /help Вывод справки по использованию Параметры перезапуска /norestart Не перезапускать после завершения установки /promptrestart Запрашивать перезапуск при необходимости /forcerestart Всегда перезапускать компьютер после завершения установки Параметры ведения журнала /l[i|w|e|a|r|u|c|m|o|p|v|x|+|!|*] <файл_журнала> i - сообщения о состоянии w - сообщения об устранимых ошибках e - все сообщения об ошибках a - запуски действий r - записи, специфические для действий u - запросы пользователя c - начальные параметры интерфейса пользователя m - сведения о выходе из-за недостатка памяти или неустранимой ошибки o - сообщения о недостатке места на диске p - свойства терминала v - подробный вывод x - дополнительные отладочные сведения + - добавление в существующий файл журнала ! - сбрасывание каждой строки в журнал * - заносить в журнал все сведения, кроме параметров "v" и "x" /log <файл_журнала> Равнозначен /l* <файл_журнала> Параметры обновления /update <Update1.msp>[;Update2.msp] Применение обновлений /uninstall <Код_Guid_обновления>[;Update2.msp] /package <Product.msi | код_продукта> Удаление обновлений продукта Параметры восстановления /f[p|e|c|m|s|o|d|a|u|v] <Product.msi | код_продукта> Восстановление продукта p - только при отсутствии файла o - если файл отсутствует или установлена старая версия (по умолчанию) e - если файл отсутствует или установлена такая же либо старая версия d - если файл отсутствует или установлена другая версия c - если файл отсутствует или контрольная сумма не совпадает с подсчитанным значением a - принудительная переустановка всех файлов u - все необходимые элементы реестра, специфические для пользователя (по умолчанию) m - все необходимые элементы реестра, специфические для компьютера (по умолчанию) s - все существующие ярлыки (по умолчанию) v - запуск из источника с повторным кэшированием локальных пакетов Настройка общих свойств [PROPERTY=PropertyValue] Обратитесь к пакету SDK установщика Windows за дополнительными сведениями по использованию командной строки. Корпорация Майкрософт. Все права защищены. В некоторых частях программы использованы разработки Independent JPEG Group. Следующая команда распакует файл path/to/file.msi в папку path/to/folder: msiexec /a path/to/file.msi /qb TARGETDIR=path/to/folder Подставим в неё пути к двум бинарям, и выполним полученные команды (ваши пути могут отличаться): msiexec /a C:\Users\New\Desktop\CVE-2025-29967\RemoteDesktop_1.2.6227.0_x64.msi /qb TARGETDIR=C:\Users\New\Desktop\CVE-2025-29967\1.2.6227 msiexec /a C:\Users\New\Desktop\CVE-2025-29967\RemoteDesktop_1.2.6228.0_x64.msi /qb TARGETDIR=C:\Users\New\Desktop\CVE-2025-29967\1.2.6228 После чего в каждом из путей появится по одному файлу и одной папке: TARGETDIR/имя_файла.msi - тут наш файл msi TARGETDIR/Remote Desktop/ - тут вложено очень много файлов скриншот, файлы в созданной папке Remote Desktop Анализ двух файлов при помощи ghidra Для анализа полученных файлов я буду использовать дизассемблер ghidra - разработанный Агенством Национальной Безопасности США, этот продукт совершенно точно не содержит никаких бекдоров и заплаток, поэтому его можно смело скачать из их официального репозитория: https://github.com/NationalSecurityAgency/ghidra Создаём новый проект и импортируем туда файлы, для начала - msrdc_1.2.6227.exe и msrdc_1.2.6228.exe. Как это сделать? Создать проект - File -> New Project (W) Импортировать файлы - File -> Import File (I) Для анализа разных версий одного и того же файла, в ghidra предусмотрен инструмент Version Tracking. Принцип его работы заключается в сессиях, каждая сессия - это разница между двумя файлами (то есть три файла или более - не поддерживается, создавайте несколько отдельных сессий). Открываем окно Version Tracking. После чего кликаем на New Session, чтобы создать новую сессию. Назовём её именем экзешника (msrdc.exe) чтобы проще было ориентироваться по ним, после чего выбираем два файла: src - версия до фикса (1.2.6227), dest - версия после фикса (1.2.6228). скриншот Кликаем Next, затем кликаем Run Precondition Checks, и снова Next, затем Finish. На данном этапе ghidra предложит вам проанализировать файлы, если вы ещё этого не делали ранее. Соглашаемся, после чего открывается два-три окна, и начнётся анализ файлов. скриншот (только не пугайтесь) На данном этапе находим окно "Version Tracking: msrdc.exe", после чего кликаем на волшебную палочку, и начнётся волшебство. Волшебство Процесс анализа: После окончания анализа: В этом же окне можно посмотреть, какие функции были изменены, и что конкретно в них поменялось: Пример для функции FUN_1400fc0e0 После анализа мы получили список функций, которые ghidra любезно скоррелировала между собой для нас. Теперь мы должны определить, в какой из этих функций находится переполнение буфера на куче.. Подумаешь, всего лишь 165 тысяч результатов нашлось. Отсеим их! Фильтр Score Score - значение, которое показывает, насколько схожи участки кода, варьируется от -1 (в нашем случае от 0) до единицы, где 1 означает идеальное совпадение (100%). в поле ввода Score Filter вводим диапазон от 0.99 до 0.999, чтобы исключить несовпавшие ни с чем участки кода (score 0) и неизменённые функции (score 1), а так-же диапазон от 0.001 до 0.99 (к нему мы вернёмся чуть позже). мы получим 104 совпадения по функциям. как показывает практика, это самые мизерные совпадения, такие как на скриншоте ниже. здесь практически не меняется код функции, или её сигнатура, это большая редкость. Мизерные псевдо-совпадения их можно смело игнорировать, так-же как и score 0 вместе с 1. в диапазоне Score от 0 до 0.001 мы получим в районе 3042 совпадений - это участки кода/функции, которые не совпали ни с одной существующей функцией - т.е были добавлены с нуля, или полностью вырезаны. они нас НЕ интересуют. в диапазоне Score от 1.0 до 1.0 мы получим в районе 161.856 совпадений - это функции, существующие и в прошлой, и в новой версии, которые вообще никак не изменились. они нас так-же НЕ интересуют. в целом, диапазона от 0.8 до 0.99 (595 совпадений) достаточно, чтобы выделить функции, которые были явно изменены. Про колонку Status Во время анализа, мы увидим лишь два значения статуса: 1 - флажок, означающий что всё корректно распознано, и проблем не возникло. 2 - замок, который означает, что где-то гидра дала сбой. Вот пример, у каких функций может быть замок. 1. Функция A, внутри которой код в духе: int A(int x) { if (x == 10) return 5; return x; } 2. Функция B, внутри которой код очень похож на A: int B(int x) { if (x == 4) return 5; return x; } Не смотря на сходство этих двух функций, они обе есть и в первом, и во втором файле. Только гидра (во время сигнатурного анализа) может случайно посчитать их за изменения, допустим: Изменение 1 - в функции A число 10 было изменено на 4 (думает, что B это A) Изменение 2 - в функции B число 4 было изменено на 10 (думает, что A это B) Когда как на самом деле, никаких изменений не происходило. Реальный пример, на который я сам наткнулся: Функции FUN_140076360 и FUN_140076300 Первое совпадение, гидра посчитала FUN_140076360 в версии 1.2.6227 за функцию FUN_140076300 в версии 1.2.228: Второе совпадение, где гидра посчитала FUN_140076300 в версии 1.2.6227 за функцию FUN_140076360 в версии 1.2.6228: Для того, чтобы икслючить эти функции, необходимо отфильтровать список по колонке Status. Далее мы проходимся по совпадением где отображается замок, и сравниваем их. Если видим подобную картину - смело избавляемся от совпадения, кликая по нему ПКМ и выбирая пункт Remove Match: Картина Так-же можно смело удалять те совпадения, код в которых похож на вызов библиотечных функций Visual Studio (C++), т.к никакого отношения к логике самого приложения они не имеют: Пример такой функции Проверки (условия) в коде Если мы видим, что в прошлой версии ПО есть какие-то проверки (блоки if, или условный прыжок в ассемблере), которые были удалены в новой версии, то скорее всего можно смело удалять такое совпадение. Потому что heap-buffer overflow - это про отсутствие важной проверки (в нашем случае на длину буфера), а не про её вырезание) И снова реальный пример Итого, после Score фильтра у нас осталось 595 совпадений. После того, как мы отфильтровали полученные совпадения, можно приступать к чуть более подробному анализу. Мы руками открываем каждую функцию и смотрим на изменения. Т.к. переполнение буфера на куче подразумевает собой отсутствие проверки на длину буфера, мы должны сфокусировать своё внимание на тех местах, где этой проверки не было, и где она была добавлена. UPD: Позже на личном опыте выяснилось, что стоковые корреляторы ghidra совершенно непригодны для patch diffing анализа, поэтому я рекомендую использовать плагин с открытым исходным кодом PatchDiffCorrelator, который добавляет дополнительные, более точные корреляторы. Что за плагин https://lolz.live/threads/8794328/ Далее, опытным путём отсеивая различные функции, рано или поздно нам удастся выйти на место, где ранее была уязвимость. Особенности эксплуатации бинарных уязвимостей Эксплуатация кучи на примере linux Для успешной эксплуатации heap-based buffer overflow на линуксе можно воспользоваться уже известными техниками (эксплуатируется преимущественно glibc): p.s Чанками для удобства называются области в памяти на куче, куда указывают наши указатели, такое название происходит от структуры malloc_chunk Чтобы разобраться, как работают некоторые из этих трюков, для начала посмотрим на структуру чанков в куче (glibc heap structure), т.е. на то, как они лежат в памяти: struct malloc_chunk { INTERNAL_SIZE_T prev_size; // предыдущий размер чанка, если он стал свободен (freed) INTERNAL_SIZE_T size; // размер текущего чанка в байтах (включая overhead) // двойная ссылка - используется только в освобождённых чанках struct malloc_chunk* fd; // forward struct malloc_chunk* bk; // back // эти поля используются только для больших чанков struct malloc_chunk* fd_nextsize; struct malloc_chunk* bk_nextsize; }; CPP struct malloc_chunk { INTERNAL_SIZE_T prev_size; // предыдущий размер чанка, если он стал свободен (freed) INTERNAL_SIZE_T size; // размер текущего чанка в байтах (включая overhead) // двойная ссылка - используется только в освобождённых чанках struct malloc_chunk* fd; // forward struct malloc_chunk* bk; // back // эти поля используются только для больших чанков struct malloc_chunk* fd_nextsize; struct malloc_chunk* bk_nextsize; }; Любой адрес, который возвращает malloc, по умолчанию указывает на поле fd. Таким образом: p2 => *fd (указатель p2 указывает на поле fd) (p2-1) => size (указатель p2 по отрицательному смещению будет указывать на поле перед fd, то есть на size) Почему мы отнимаем единицу, а не кол-во байт, которое занимает поле? потому что указатель p2 хранит адрес и под капотом имеет тип size_t / uintptr_t (называйте как угодно), следовательно придерживается арифметики указателей. Подробнее про указатели и их арифметику вы можете почитать выше (спойлер "Основы"). fastbin dup Эксплуатируем malloc, заставляя его вернуть нам указатель на уже выделенную область в куче при помощи fastbin free list. // этот код демонстрирует атаку двойного освобождения (double-free) fastbin чанков // инициализируем 8 fastbin чанков void* ptrs[8]; for (int i = 0; i < 8; i++) { ptrs[i] = malloc(8); } // аллоцируем 3 буфера аналогичной длины через calloc: int* a = calloc(1, 8); int* b = calloc(1, 8); int* c = calloc(1, 8); // освобождаем первый из них: free(a); // если мы снова освободим первый буфер, это вызовет вылет программы, // потому что адрес a находится на вершине списка освобождённых чанков (free list) // поэтому давайте попробуем освободить b: free(b); // мы можем освободить fastbin чанк a снова, // т.к теперь он не находится на вершине списка free list (её изначальное место занял b) free(a); // теперь free list выглядит следующим образом: // [ a (new), b (new), a (old) ] // направление head => tail // если мы трижды вызовем аллокацию, мы получим адрес буфера a второй раз: a = calloc(1, 8); b = calloc(1, 8); c = calloc(1, 8); // третий вызов calloc вернул адрес буфера a printf("magic: c = %p, a = %p", c, a); cpp // этот код демонстрирует атаку двойного освобождения (double-free) fastbin чанков // инициализируем 8 fastbin чанков void* ptrs[8]; for (int i = 0; i < 8; i++) { ptrs[i] = malloc(8); } // аллоцируем 3 буфера аналогичной длины через calloc: int* a = calloc(1, 8); int* b = calloc(1, 8); int* c = calloc(1, 8); // освобождаем первый из них: free(a); // если мы снова освободим первый буфер, это вызовет вылет программы, // потому что адрес a находится на вершине списка освобождённых чанков (free list) // поэтому давайте попробуем освободить b: free(b); // мы можем освободить fastbin чанк a снова, // т.к теперь он не находится на вершине списка free list (её изначальное место занял b) free(a); // теперь free list выглядит следующим образом: // [ a (new), b (new), a (old) ] // направление head => tail // если мы трижды вызовем аллокацию, мы получим адрес буфера a второй раз: a = calloc(1, 8); b = calloc(1, 8); c = calloc(1, 8); // третий вызов calloc вернул адрес буфера a printf("magic: c = %p, a = %p", c, a); house of spirit Освобождаем несуществующий (ложный) чанк fastbin, чтобы заставить malloc вернуть нам указатель на произвольную область в памяти. Данный трюк был продемонстрирован на CTF 2014 OREO) // массив из 7 чанков (указатели) void* chunks[7]; // ШАГ 1: // инициализируем 7 fastbin чанками (исходя из размера) for (int i = 0; i < 7; i++) { chunks[i] = malloc(0x30); } // сразу же освобождаем их for (int i = 0; i < 7; i++) { free(chunks[i]); } // ШАГ 2: // подготавливаем фейковые чанки // не смотрите на число 10 - fake_chunks это просто область в памяти // для последующих аллокаций (на которые указывает fastbinY) long fake_chunks[10] __attribute__ ((aligned (0x10))); printf("fake chunk located at %p", fake_chunks); // из-за смещений (struct allign), этот массив располагается на двух чанках: printf("first chunk at %p, second chunk at %p", &fake_chunks[1], &fake_chunks[9]); // chunk.size для этого чанка должен на 16 байт превышать размер самого чанка // чтобы вместить данные структуры malloc_chunk, но при этом // соответствовать критериям fastbin чанка (занимать <= 128 байт, актуально для 64 битной системы) // бит PREV_INUSE (первый младший бит) игнорируется функцией free для // чанков, аллоцированных по принципу fastbin, // но с битами IS_MMAPPED (второй младший бит) и // NON_MAIN_ARENA (третий младший бит) возникнет проблемка // обратите внимание, что chunk.size должен быть размером следующей аллокации malloc // (т.е аргумент функции malloc при следующем её вызове должен быть равен chunk.size), // округлённый до внутреннего размера, используемого в реализации malloc (речь идёт о struct malloc_chunk) // то есть в 64 битной системе значения 0x30-0x38 будут округлены до 0x40 // поэтому это число вполне подходит нам // теперь установим размер фейкового чанка равным 0x40. // таким образом, аллокатор glibc malloc будет думать, что // имеет дело с валидным, нормальным чанком fake_chunks[1] = 0x40; // это поле struct malloc_chunk::size // размер chunk.size следующего фейкового чанка (который начинается с &fake_chunks[9]) // так-же должен соответствовать норме: // - быть больше чем 2 * SIZE_SZ (т.е. больше 16 в 64 битных системах) // - быть меньше чем av->system_mem (по умолчанию это 128 кб) // для того, чтобы пройти проверки целостности nextsize (поле fd_nextsize) // === ВНИМАНИЕ === // если не соблюсти вышеперечисленные условия, аллокатор просто вызовет abort() // теперь устанавливаем значение второго чанка на 0x1234 // чтобы освобождение первого чанка прошло успешно: fake_chunks[9] = 0x1234; // ШАГ 3: // освобождаем первый чанк // прошу заметить, что адрес первого чанка // должен быть выровнен по 16 байтам void* victim = &fake_chunks[2]; free(victim); // ШАГ 4: // теперь вызов calloc должен вернуть нам указатель на фейковый чанк &fake_chunks[2] // malloc тоже способен это сделать, но придётся вызвать его по меньшей мере 8 раз void* allocated = calloc(1, 0x30); printf("allocated at %p, fake chunk: %p", allocated, victim); cpp // массив из 7 чанков (указатели) void* chunks[7]; // ШАГ 1: // инициализируем 7 fastbin чанками (исходя из размера) for (int i = 0; i < 7; i++) { chunks[i] = malloc(0x30); } // сразу же освобождаем их for (int i = 0; i < 7; i++) { free(chunks[i]); } // ШАГ 2: // подготавливаем фейковые чанки // не смотрите на число 10 - fake_chunks это просто область в памяти // для последующих аллокаций (на которые указывает fastbinY) long fake_chunks[10] __attribute__ ((aligned (0x10))); printf("fake chunk located at %p", fake_chunks); // из-за смещений (struct allign), этот массив располагается на двух чанках: printf("first chunk at %p, second chunk at %p", &fake_chunks[1], &fake_chunks[9]); // chunk.size для этого чанка должен на 16 байт превышать размер самого чанка // чтобы вместить данные структуры malloc_chunk, но при этом // соответствовать критериям fastbin чанка (занимать <= 128 байт, актуально для 64 битной системы) // бит PREV_INUSE (первый младший бит) игнорируется функцией free для // чанков, аллоцированных по принципу fastbin, // но с битами IS_MMAPPED (второй младший бит) и // NON_MAIN_ARENA (третий младший бит) возникнет проблемка // обратите внимание, что chunk.size должен быть размером следующей аллокации malloc // (т.е аргумент функции malloc при следующем её вызове должен быть равен chunk.size), // округлённый до внутреннего размера, используемого в реализации malloc (речь идёт о struct malloc_chunk) // то есть в 64 битной системе значения 0x30-0x38 будут округлены до 0x40 // поэтому это число вполне подходит нам // теперь установим размер фейкового чанка равным 0x40. // таким образом, аллокатор glibc malloc будет думать, что // имеет дело с валидным, нормальным чанком fake_chunks[1] = 0x40; // это поле struct malloc_chunk::size // размер chunk.size следующего фейкового чанка (который начинается с &fake_chunks[9]) // так-же должен соответствовать норме: // - быть больше чем 2 * SIZE_SZ (т.е. больше 16 в 64 битных системах) // - быть меньше чем av->system_mem (по умолчанию это 128 кб) // для того, чтобы пройти проверки целостности nextsize (поле fd_nextsize) // === ВНИМАНИЕ === // если не соблюсти вышеперечисленные условия, аллокатор просто вызовет abort() // теперь устанавливаем значение второго чанка на 0x1234 // чтобы освобождение первого чанка прошло успешно: fake_chunks[9] = 0x1234; // ШАГ 3: // освобождаем первый чанк // прошу заметить, что адрес первого чанка // должен быть выровнен по 16 байтам void* victim = &fake_chunks[2]; free(victim); // ШАГ 4: // теперь вызов calloc должен вернуть нам указатель на фейковый чанк &fake_chunks[2] // malloc тоже способен это сделать, но придётся вызвать его по меньшей мере 8 раз void* allocated = calloc(1, 0x30); printf("allocated at %p, fake chunk: %p", allocated, victim); overlapping chunks (glibc < 2.29) Эксплуатируем перезапись поля size у освобождённого чанка, чтобы следующий чанк был аллоцирован на месте одного из предыдущих, тем самым позволяя перезаписывать данные прошлых чанков, обращаясь к новому. #include <stdlib.h> #include <string.h> #include <stdint.h> int main(int argc , char* argv[]) { // создаём 4 указателя на 4 чанка соответственно, пока они пусты intptr_t *p1,*p2,*p3,*p4; // выделяем память под первые 3 чанка, они попадут в кучу (heap) // для удобства можно вывести их в stdout и посмотреть, где именно они были аллоцированы p1 = malloc(0x500 - 8); p2 = malloc(0x500 - 8); p3 = malloc(0x80 - 8); // забиваем их произвольными данными, в нашем случае для наглядности используются цифры 1, 2 и 3 memset(p1, '1', 0x500 - 8); memset(p2, '2', 0x500 - 8); memset(p3, '3', 0x80 - 8); // освобождаем второй чанк // на этом моменте начинается веселье - p2 указывает на освобождённый unsorted bin чанк (поле malloc_chunk::fd) // т.к мы освободили память, теперь туда может быть записано что угодно // при этом мы всё ещё в состоянии перезаписать поле malloc_chunk::size для достижения этого трюка (см. ниже) free(p2); // обратите внимание: // фактический размер "злого" чанка это размеры p2 + p3, но без учёта malloc_chunk::size (для дальнейшей перезаписи данных в p3) int evil_chunk_size = 0x581; // цифра 8 здесь означает sizeof(size_t), размер поля malloc_chunk::size, для 32 битных систем здесь логично будет 4 int evil_region_size = 0x580 - 8; // на этом моменте происходит самое интересное - мы записываем последние биты освобождённого чанка, отвечающие за длину чанка // после данного трюка, размер чанка будет установлен в 0x581 байт // адрес p2 - 1 указывает на поле размера чанка, malloc_chunk::size *(p2-1) = evil_chunk_size; // аллоцируем следующий чанк // этот вызов malloc вернёт адрес области в памяти, принадлежавшей предыдущему освобождённому чанку (p2) p4 = malloc(evil_region_size); // теперь, копируя что угодно в p4, будет перезаписан кусок данных в p3 // перезаписываем чанки чтобы в этом убедиться: memset(p4, '4', evil_region_size); memset(p3, '3', 80); return 0; } CPP #include <stdlib.h> #include <string.h> #include <stdint.h> int main(int argc , char* argv[]) { // создаём 4 указателя на 4 чанка соответственно, пока они пусты intptr_t *p1,*p2,*p3,*p4; // выделяем память под первые 3 чанка, они попадут в кучу (heap) // для удобства можно вывести их в stdout и посмотреть, где именно они были аллоцированы p1 = malloc(0x500 - 8); p2 = malloc(0x500 - 8); p3 = malloc(0x80 - 8); // забиваем их произвольными данными, в нашем случае для наглядности используются цифры 1, 2 и 3 memset(p1, '1', 0x500 - 8); memset(p2, '2', 0x500 - 8); memset(p3, '3', 0x80 - 8); // освобождаем второй чанк // на этом моменте начинается веселье - p2 указывает на освобождённый unsorted bin чанк (поле malloc_chunk::fd) // т.к мы освободили память, теперь туда может быть записано что угодно // при этом мы всё ещё в состоянии перезаписать поле malloc_chunk::size для достижения этого трюка (см. ниже) free(p2); // обратите внимание: // фактический размер "злого" чанка это размеры p2 + p3, но без учёта malloc_chunk::size (для дальнейшей перезаписи данных в p3) int evil_chunk_size = 0x581; // цифра 8 здесь означает sizeof(size_t), размер поля malloc_chunk::size, для 32 битных систем здесь логично будет 4 int evil_region_size = 0x580 - 8; // на этом моменте происходит самое интересное - мы записываем последние биты освобождённого чанка, отвечающие за длину чанка // после данного трюка, размер чанка будет установлен в 0x581 байт // адрес p2 - 1 указывает на поле размера чанка, malloc_chunk::size *(p2-1) = evil_chunk_size; // аллоцируем следующий чанк // этот вызов malloc вернёт адрес области в памяти, принадлежавшей предыдущему освобождённому чанку (p2) p4 = malloc(evil_region_size); // теперь, копируя что угодно в p4, будет перезаписан кусок данных в p3 // перезаписываем чанки чтобы в этом убедиться: memset(p4, '4', evil_region_size); memset(p3, '3', 80); return 0; } А теперь разберёмся, почему это работает. Возьмём следующий код: char *A, *B, *C; // три буфера // выделяем первый чанк // здесь произойдёт переполнение A = malloc(0x100 - 8); // выделяем второй чанк // чуть ниже мы его "освободим" B = malloc(0x100 - 8); // выделяем третий чанк // после реалокации произойдёт наложение чанка B на чанк C C = malloc(0x80 - 8); // теперь посмотрим, куда указывает указатель C (и где он заканчивается): printf("C begin: %p, C end: %p", C, C + 0x80 - 8); // освобождаем второй чанк // после этого структура malloc_chunk будет считаться освобождённой (статус freed) free(B); // теперь перезаписываем первый чанк (выходим за его пределы) // после этого трюка второй чанк будет занимать 0x181 байт (вместо 0x101) // потому что следом за A идёт начало чанка B: // struct malloc_chunk { // INTERNAL_SIZE_T prev_size; // размер предыдущего чанка, если он свободен // следовательно, перезаписав это значение, мы сломаем логику поведения malloc A[0x100 - 8] = 0x81; // реаллоцируем чанк B // обратите внимание на размер - он гораздо больше, чем был (на 0x80 байт) // и стал равен прошлому размеру B + размеру C B = malloc(0x100 + 0x80 - 8); // теперь смотрим, какую область занимает чанк B, и удивляемся: cpp char *A, *B, *C; // три буфера // выделяем первый чанк // здесь произойдёт переполнение A = malloc(0x100 - 8); // выделяем второй чанк // чуть ниже мы его "освободим" B = malloc(0x100 - 8); // выделяем третий чанк // после реалокации произойдёт наложение чанка B на чанк C C = malloc(0x80 - 8); // теперь посмотрим, куда указывает указатель C (и где он заканчивается): printf("C begin: %p, C end: %p", C, C + 0x80 - 8); // освобождаем второй чанк // после этого структура malloc_chunk будет считаться освобождённой (статус freed) free(B); // теперь перезаписываем первый чанк (выходим за его пределы) // после этого трюка второй чанк будет занимать 0x181 байт (вместо 0x101) // потому что следом за A идёт начало чанка B: // struct malloc_chunk { // INTERNAL_SIZE_T prev_size; // размер предыдущего чанка, если он свободен // следовательно, перезаписав это значение, мы сломаем логику поведения malloc A[0x100 - 8] = 0x81; // реаллоцируем чанк B // обратите внимание на размер - он гораздо больше, чем был (на 0x80 байт) // и стал равен прошлому размеру B + размеру C B = malloc(0x100 + 0x80 - 8); // теперь смотрим, какую область занимает чанк B, и удивляемся: В памяти это будет выглядеть примерно следующим образом (картинка из интернета): Таким образом, мы перезаписали размер чанка B при помощи выхода за границы чанка A, после чего реаллоцировав чанк B, он залез на соседний чанк C. UPD: Статья была полностью переписана и объединена с другими частями в единое целое, было затронуто большое количество сложных тем. Надеюсь, она станет отличной отправной точкой в изучении бинарных уязвимостей, реверс инженеринга и эксплойтинга, а если вы имели такой опыт прежде - поможет вам узнать для себя что-то новое)