Принцип работы перераспределения и освобождения. Как работают обычные умные указатели в C ++? Как распознать операторов памяти с помощью дизассемблера, не понимающего его истинной природы? Чтобы понять все это, мы должны разобрать механизмы динамического распределения памяти приложения (то есть кучи) двух самых популярных компиляторов по байтам и выявить различия в их работе. Поэтому в статье нас ждут многочисленные листинги дизассемблеров и кода C ++.
Указатель this — настоящий золотой ключик или, если хотите, спасательный круг, чтобы не утонуть в бурном океане ООП. Именно благодаря этому вы можете определить принадлежность вызываемой функции к тому или иному классу. Поскольку все невиртуальные функции объекта вызываются напрямую — по фактическому адресу, объект как бы разбивается на составляющие его функции на этапе компиляции. Без этих указателей восстановить иерархию функций было бы принципиально невозможно!
Поэтому очень важно правильно его идентифицировать. Единственная проблема в том, как это определить по указателям на массивы и структуры? В конце концов, экземпляр класса идентифицируется этим указателем (если он выделил память, это экземпляр класса), но то же самое по определению является указателем, относящимся к экземпляру класса. Замкнутый круг! К счастью, есть одна уязвимость … Код, управляющий этим указателем, довольно специфичен, что отличает его от всех других указателей.
Фактически, у каждого компилятора свой почерк, который настоятельно рекомендуется выучить, дизассемблируя свои собственные программы на C ++, но есть универсальные рекомендации, применимые к большинству реализаций. Поскольку это неявный аргумент каждой функции-члена класса, имеет смысл отложить обсуждение его идентификации до раздела «Идентификация аргументов функции». Здесь мы собираемся обсудить, как наиболее популярные компиляторы реализуют передачу этого указателя.
Мы, конечно, говорим здесь об архитектуре x64. На 32-битной платформе параметры, выровненные по 32-битному размеру, передаются через стек. С другой стороны, на 64-битной платформе все интереснее: первые четыре целочисленных аргумента передаются в регистрах RCX, RDX, R8, R9. Если целочисленных аргументов больше, остальные помещаются в стек. Аргументы со значениями с плавающей запятой передаются в регистры XMXMM0, XMM1, XMM2, M3. В этом случае 16-битные аргументы передаются по ссылке. Обратите внимание, что это соглашение о вызовах в операционных системах Microsoft (Microsoft ABI); В Unix-подобных системах все иначе. Но не будем заострять на них внимание.
Оба протестированных мной компилятора, Visual C ++ 2019 и C ++ Builder 10.3, независимо от соглашения о вызове функций (__cdecl, __clrcall, __stdcall, __fastcall, __thiscall) передают указатель this в регистре RCX, что соответствует его природе: это целочисленн ый аргумент.
Операторы new и delete переводятся компилятором в вызовы библиотечных функций, которые могут распознаваться так же, как и обычные библиотечные функции. В частности, IDA Pro может автоматически распознавать библиотечные функции, снимая эту проблему с плеч исследователя. Однако не у всех есть IDA Pro и далеко не всегда в нужное время под рукой, к тому же она не знает всех функций библиотеки, а из тех, что знает, не всегда распознает new и delete … Словом есть основания идентифицировать их вручную.
Реализация new и delete может быть любой, но большинство компиляторов Windows редко реализуют сами функции кучи. Почему? Доступ к службам операционной системы намного проще. Однако было бы наивно ожидать, что HeapAlloc появится вместо new и HeapFree вместо delete. Нет, компилятор не такой простой! Может ли он отказать себе в удовольствии «вырезать матрешки»? Оператор new транслируется в новую функцию, которая вызывает malloc для выделения памяти, а malloc, в свою очередь, вызывает HeapAlloc (или его аналог, в зависимости от реализации библиотеки памяти), своего рода «оболочку» для одноименной процедуры Win32 API. Образ с общей памятью такой же.
Углубляться в дебри вложенных вызовов слишком утомительно. Нельзя ли new
и delete
идентифицировать как‑нибудь иначе, с меньшими трудозатратами и без лишней головной боли? Разумеется, можно! Давай вспомним все, что мы знаем о new:
this
); отсюда будет очень много вызовов типа mov ecx, 1 ; size
call XXX
где XXX
и есть адрес new
! Вообще же, типичный размер объектов составляет менее сотни байтов… ищи часто вызываемую функцию с аргументом‑константой меньше ста байтов;
функция new
— одна из самых популярных библиотечных функций, ищи функцию с «толпой» перекрестных ссылок;
самое характерное: new
возвращает указатель this
, а this
очень легко идентифицировать даже при беглом просмотре кода (обычно он возвращается в регистре RCX
);
возвращенный new
результат всегда проверяется на равенство нулю (операторами типа test
, RCX
), и, если он действительно равен нулю, конструктор (если он есть) не вызывается.
New имеет более чем достаточно «родинок» для быстрой и надежной идентификации, нет необходимости тратить время на анализ кода для этой функции! Единственное, что следует помнить: new используется не только для создания новых экземпляров объектов, но также для выделения памяти для массивов (структур) и иногда для простых переменных (например, int * x = new int, что вообще безумие, но некоторые делают). К счастью, разницу между ними очень легко отличить — ни массивы, ни структуры, ни простые переменные не имеют указателя this!
Сложнее идентифицировать delete. Каких‑либо характерных признаков эта функция не имеет. Да, она принимает единственный аргумент — указатель на освобождаемый регион памяти, причем в подавляющем большинстве случаев это указатель this. Но помимо нее, this принимают десятки, если не сотни других функций! Раньше в эпоху 32-битных камней у исследователя была удобная зацепка за то, что delete в большинстве случаев принимал указатель this через стек, а остальные функции — через регистр. В настоящее же время, как мы уже неоднократно убеждались, любые функции принимают параметры через регистры:
mov rcx, [rsp+58h+block] ; block
call operator delete(void *,unsigned __int64)
В данном случае IDA без замешательств распознала delete
.
Кроме того, удаление ничего не возвращает, но сколько функций делают то же самое? Единственная подсказка заключается в том, что вызов удаления следует за вызовом деструктора (если есть), но поскольку конструктор идентифицируется как функция перед удалением, он создает порочный круг!
Осталось только проанализировать содержимое функции: delete рано или поздно вызывает HeapFree (хотя здесь тоже есть варианты: например, Borland / Embarcadero содержит библиотеки, которые работают с кучей на низком уровне и освободите память, вызвав VirtualFree). К счастью, IDA Pro распознает удаление в большинстве случаев, и вам не нужно напрягаться.
А что произойдет, если IDA не распознает delete
? Код будет выглядеть прщзимерно так:
mov rcx, [rsp+58h+block] ; block
call XXX
cmp [rsp+58h+block], 0
jnz short loc_1400010B0
Сразу после введения интеллектуальных указателей в стандарт C ++ они приобрели популярность среди программистов. Это и понятно: их использование избавило программиста от головной боли при распределении памяти и работе с ней — теперь вам не нужно беспокоиться об ее освобождении.
Скомпилируем простой пример кода:
#include <iostream>
#include <memory> // Добавляет поддержку умных указателей
class MyClass {
public:
void print()
{
std::cout << "Hello Visual C++" << std::endl;
}
};
int main()
{
auto c = std::make_unique<MyClass>();
c->print();
std::cin.get();
}
Только не забудь отключить оптимизацию, чтобы увидеть детальную работу компилятора. Результат дизассемблирования будет такой:
main proc near
_Right = qword ptr -28h
result = std::unique_ptr<MyClass,std::default_delete<MyClass> > ptr -20h
var_18 = std::unique_ptr<MyClass,std::default_delete<MyClass> > ptr -18h
var_10 = qword ptr -10h
sub rsp, 48h
mov rax, cs:__security_cookie
xor rax, rsp
mov [rsp+48h+var_10], rax
mov edx, 8 ; unsigned __int64
lea rcx, [rsp+48h+var_18] ; this
call std::unique_ptr<MyClass,std::default_delete<MyClass>>::__autoclassinit2(unsigned __int64)
lea rcx, [rsp+48h+result] ; result
call std::make_unique<MyClass,,0>(void)
mov [rsp+48h+_Right], rax
mov rdx, [rsp+48h+_Right] ; _Right
lea rcx, [rsp+48h+var_18] ; this
call std::unique_ptr<MyClass,std::default_delete<MyClass>>::unique_ptr<MyClass,std::default_delete<MyClass>>(std::unique_ptr<MyClass,std::default_delete<MyClass>> &&)
nop
lea rcx, [rsp+48h+result] ; this
call std::unique_ptr<MyClass,std::default_delete<MyClass>>::~unique_ptr<MyClass,std::default_delete<MyClass>>(void)
lea rcx, [rsp+48h+var_18] ; this
call std::unique_ptr<MyClass,std::default_delete<MyClass>>::operator->(void)
mov rcx, rax ; this
call MyClass::print(void)
mov rcx, cs:std::basic_istream<char,std::char_traits<char>> std::cin
call cs:std::basic_istream<char,std::char_traits<char>>::get(void)
nop
lea rcx, [rsp+48h+var_18] ; this
call std::unique_ptr<MyClass,std::default_delete<MyClass>>::~unique_ptr<MyClass,std::default_delete<MyClass>>(void)
xor eax, eax
mov rcx, [rsp+48h+var_10]
xor rcx, rsp ; StackCookie
call __security_check_cookie
add rsp, 48h
retn
main endp
Результат компиляции будет таким:
main proc near
var_38 = qword ptr -38h
var_30 = qword ptr -30h
var_28 = qword ptr -28h
block = qword ptr -20h
var_18 = qword ptr -18h
sub rsp, 58h
mov ecx, 1 ; size
call operator new(unsigned __int64)
mov [rsp+58h+var_30], rax
cmp [rsp+58h+var_30], 0
jz short loc_140001067
mov rax, [rsp+58h+var_30]
mov [rsp+58h+var_28], rax
jmp short loc_140001070
loc_140001067:
mov [rsp+58h+var_28], 0
loc_140001070:
mov rax, [rsp+58h+var_28]
mov [rsp+58h+var_38], rax
mov rcx, [rsp+58h+var_38] ; this
call MyClass::print(void)
mov rax, [rsp+58h+var_38]
mov [rsp+58h+block], rax
mov edx, 1 ; __formal
mov rcx, [rsp+58h+block] ; block
call operator delete(void *,unsigned __int64)
cmp [rsp+58h+block], 0
jnz short loc_1400010B0
mov [rsp+58h+var_18], 0
jmp short loc_1400010C3
loc_1400010B0:
mov [rsp+58h+var_38], 8123h
mov rax, [rsp+58h+var_38]
mov [rsp+58h+var_18], rax
loc_1400010C3:
mov rcx, cs:std::basic_istream<char,std::char_traits<char>> std::cin
call cs:std::basic_istream<char,std::char_traits<char>>::get(void)
xor eax, eax
add rsp, 58h
retn
main endp
Этот листинг выглядит более привычным и наглядным. Между тем вернемся к предыдущему листингу, он для нас более интересен. Из него видно, что объект создается в функции std::
, именно в ней вызывается оператор new
:
class std::unique_ptr<class MyClass, struct std::default_delete<class MyClass>> std::make_unique<class MyClass, , 0>(void) proc near
...
mov [rsp+arg_0], rcx
sub rsp, 58h
mov [rsp+58h+var_38], 0
mov ecx, 1 ; size
call operator new(unsigned __int64)
...
Уничтожает объект функция
std::
:
public: std::unique_ptr<class MyClass, struct std::default_delete<class MyClass>>::~unique_ptr<class MyClass, struct std::default_delete<class MyClass>>(void) proc near
arg_0 = qword ptr 8
mov [rsp+arg_0], rcx
sub rsp, 28h
mov rax, [rsp+28h+arg_0]
cmp qword ptr [rax], 0
jz short loc_140001131
mov rax, [rsp+28h+arg_0]
mov rcx, rax ; this
call std::forward<MyClass *>(MyClass * &)
mov rcx, [rsp+28h+arg_0]
mov rdx, [rcx] ; _Ptr
mov rcx, rax ; this
call std::default_delete<MyClass>::operator()(MyClass *)
loc_140001131:
add rsp, 28h
retn
В начале функции выполняется проверка: cmp
, то есть, если указатель на объект нулевой, не имеет смысла его удалять. В ином случае его удаляет библиотечная функция std::
, она работает именно с уникальными указателями (в стандарте, кроме них, присутствуют еще shared_ptr
), внутри нее происходит вызов оператора delete
:
public: void std::default_delete<class MyClass>::operator()(class MyClass *)const proc near
block = qword ptr -18h
var_10 = qword ptr -10h
arg_0 = qword ptr 8
arg_8 = qword ptr 10h
mov [rsp+arg_8], rdx
mov [rsp+arg_0], rcx
sub rsp, 38h
mov rax, [rsp+38h+arg_8]
mov [rsp+38h+block], rax
mov edx, 1 ; __formal
mov rcx, [rsp+38h+block] ; block
call operator delete(void *,unsigned __int64)
С Visual C++ разобрались, а как обстоят дела в стане Embarcadero? Сможет ли IDA разобраться в его нагромождениях кода? Дизассемблируем тот же пример. Однако в C++Builder он будет иметь немного другой вид:
#include <stdio.h>
#include <iostream>
#include <memory>
class MyClass {
public:
void __fastcall print()
{
std::cout << "Hello C++ Builder" << std::endl;
}
};
int _tmain(int argc, _TCHAR* argv[])
{
auto c = std::unique_ptr<MyClass>();
c->print();
std::cin.get();
return 0;
}
Отключение оптимизации
В результате дизассемблерный листинг функции _tmain
будет выглядеть следующим образом:
main proc near
var_34 = dword ptr -34h
var_30 = qword ptr -30h
var_18 = byte ptr -18h
var_10 = qword ptr -10h
var_8 = dword ptr -8
var_4 = dword ptr -4
arg_2C = dword ptr 34h
arg_30 = qword ptr 38h
arg_38 = byte ptr 40h
sub rsp, 58h
mov [rsp+58h+var_4], 0
mov [rsp+58h+var_8], ecx
mov [rsp+58h+var_10], rdx
lea rdx, [rsp+58h+var_18]
mov rcx, rdx
mov [rsp+58h+var_30], rdx
call std::unique_ptr<MyClass,std::default_delete<MyClass>>::unique_ptr<std::default_delete<MyClass>,void>(void)
mov rcx, [rsp+58h+var_30]
call std::unique_ptr<MyClass,std::default_delete<MyClass>>::operator->(void)
mov rcx, rax
call MyClass::print(void)
jmp short $+2
loc_40137B:
lea rcx, qword_432698
call std::istream::get(void)
mov [rsp+58h+var_34], eax
jmp short $+2
loc_40138D:
lea rcx, [rsp+58h+var_18]
mov [rsp+58h+var_4], 0
call std::unique_ptr<MyClass,std::default_delete<MyClass>>::~unique_ptr()
jmp short loc_4013BA
lea rcx, [rsp+arg_38]
mov r8d, edx
mov [rsp+arg_30], rax
mov [rsp+arg_2C], r8d
call std::unique_ptr<MyClass,std::default_delete<MyClass>>::~unique_ptr()
jmp short loc_4013C3
loc_4013BA:
mov eax, [rsp+58h+var_4]
add rsp, 58h
retn
loc_4013C3:
mov rcx, [rsp+arg_30]
call _Unwind_Resume
ud2
off_4013CF dq offset unk_4290DC
main endp
К нашему удовлетворению, и в этом случае IDA успешно распознала конструкции обновленного стандарта C++. Во многом этот код похож на дизассемблированный листинг, изготовленный компилятором Visual C++. Между тем есть различие при создании объекта. В этом листинге нет вызова make_unique
. Попробуем разобраться в тонкостях. Вот так выглядит вызов:
call std::unique_ptr<MyClass,std::default_delete<MyClass>>::unique_ptr<std::default_delete<MyClass>,void>(void)
В результате мы попадаем в следующую функцию: public std::unique_ptr<MyClass, std::default_delete<MyClass>>::unique_ptr<std::default_delete<MyClass>, void>(void)
std::unique_ptr<MyClass, std::default_delete<MyClass>>::unique_ptr<std::default_delete<MyClass>, void>(void) proc near
var_C = dword ptr -0Ch
var_8 = qword ptr -8
sub rsp, 38h
mov [rsp+38h+var_8], rcx
mov rcx, [rsp+38h+var_8]
xor eax, eax
mov edx, eax
call std::_Unique_ptr_base<MyClass,std::default_delete<MyClass>>::_Unique_ptr_base<MyClass*>(MyClass*)
jmp short $+2
loc_401559:
add rsp, 38h
retn
mov ecx, edx
mov [rsp+38h+var_C], ecx
mov rcx, rax
call __clang_call_terminate
jmp short loc_401559
off_40156E dq offset unk_4290FC
Здесь и создается базовый указатель. Обрати внимание на строчку, в которой вызывается конструктор _Unique_ptr_base
:
std::_Unique_ptr_base<MyClass,std::default_delete<MyClass>>::_Unique_ptr_base<MyClass*>(MyClass*)
sub_***
.В некоторых, между прочим достаточно популярных, руководствах по программированию на C++ встречаются призывы всегда выделять память именно с использованием new
, а не malloc
, поскольку new
опирается на эффективные средства управления памятью самой операционной системы, а malloc
реализует собственный (и достаточно тормозной) менеджер кучи. Все это грубые натяжки! Стандарт вообще ничего не говорит о реализации кучи, и какая функция окажется эффективнее, наперед неизвестно. Все зависит от конкретных библиотек конкретного компилятора.
Рассмотрим, как устроено управление памятью в штатных библиотеках двух популярных компиляторов — Microsoft Visual C++ и Embarcadero C++ — на таком незамысловатом примере:
#include <iostream>
class MyClass1 {
public:
void print()
{
std::cout << "I'm allocated by new" << std::endl;
}
};
class MyClass2 {
public:
void print()
{
std::cout << "I'm allocated by malloc" << std::endl;
}
};
int main()
{
auto c1 = new MyClass1();
c1->print();
delete c1;
MyClass2 *c2 = (MyClass2*)malloc(sizeof(MyClass2));
if (c2 == NULL)
std::cout << "error";
c2->print();
free(c2);
std::cin.get();
}
Собственно, в нем показаны оба случая выделения и очистки памяти: от языков C и C++.
В Visual C++ и new
, и malloc
приводят к вызову библиотечной функции __imp_malloc
. Разница лишь в том, что malloc
сразу заменяется __imp_malloc
, а new
выполняет дополнительные операции, но в итоге все равно вызывает __imp_malloc
.
Стоит ли из‑за этого отказываться от new
в пользу сишного malloc
? Раньше, лет двадцать назад, возможно, это давало преимущество, но сейчас делать что‑то подобное категорически не надо. И хотя твой код не будет работать быстрее, но и медленнее он не станет. Тем более оператор new
делает операцию по выделению памяти безопаснее. Взглянем на его дизассемблерный листинг:
void * operator new(unsigned __int64) proc near
push rbx
sub rsp, 20h
mov rbx, rcx
jmp short loc_14000184A
loc_14000183B: ; CODE XREF: operator new(unsigned __int64)+22↓j
mov rcx, rbx
call _callnewh_0
test eax, eax
jz short loc_14000185A
mov rcx, rbx ; Size
loc_14000184A: ; CODE XREF: operator new(unsigned __int64)+9↑j
Сюда (в третий блок) выполняется безусловный переход из первого блока кода. Здесь вызывается malloc_0
, за которым кроется _imp_malloc
. Далее проверяется выделенная память (наполнение регистра RAX
). Если память выделена, завершаем функцию. В обратном случае, когда выделить ничего не удалось, прыгаем во второй блок, где с помощью _callnewh_0
, за которой также скрывается _imp_malloc
, выделяем память в 32-битных условиях, так как далее следует проверка заполнения регистра EAX
.
call malloc_0
test rax, rax
jz short loc_14000183B
add rsp, 20h
pop rbx
retn
loc_14000185A: ; CODE XREF: operator new(unsigned __int64)+15↑j
Если обе попытки выделить память провалились, управление передается в четвертый блок. А здесь все плохо: в зависимости от состояния регистра RBX
могут быть выброшены разные исключения.
cmp rbx, 0FFFFFFFFFFFFFFFFh
jz short loc_140001866
call __scrt_throw_std_bad_alloc(void)
align 2
loc_140001866: ; CODE XREF: operator new(unsigned __int64)+2E↑j
call __scrt_throw_std_bad_array_new_length(void)
void * operator new(unsigned __int64) endp
Библиотечная функция __imp_malloc
языка C/C++ для выделения памяти вызывает HeapAlloc
. И на этом портируемость заканчивается, поскольку данная функция принадлежит Win32 API. Наконец, HeapAlloc
вызывает VirtualAlloc
. И только после этого мы получаем память для создаваемого объекта — сколько закулисной работы!
Что же у нас с очисткой памяти? Оба оператора, delete
и free
, вызывают библиотечную функцию _imp_free
. Так же как и в прошлом случае, оператор free
— напрямую, delete
— косвенно. Функция _imp_free
, в свою очередь, вызывает HeapFree
, а последняя — VirtuaFree
из Win32 API для освобождения занятой памяти.
Совсем по‑другому ведет себя Embarcadero C++! Этот зверь в наследство от Borland C++ получил собственный менеджер кучи, основанный на «прямом» вызове системных функций VirtualAlloc
/VirtualFree
. В кавычках, потому что вызовы ни фига не прямые, а идут в обход библиотечных функций. Компилятор запихивает в программу столько сквозного кода, который, ясное дело, увеличивает и тормозит программу. Взглянем на дизассемблерный листинг функции _malloc_init
, которая сквозным образом вызывается оператором new
и функцией malloc
:
_malloc_init proc near
push rsi
sub rsp, 20h
mov rsi, rcx
call __CRTL_MEM_CheckBorMem
test eax, eax
jz short loc_40DE8A
mov rax, cs:off_42C078
jmp short loc_40DEDA
loc_40DE8A: ; CODE XREF: _malloc_init+F↑j
lea rax, _internal_free
mov cs:off_42C070, rax
lea rax, _internal_malloc ; Здесь происходит вызов функции
mov cs:off_42C078, rax ; для выделения памяти
lea rcx, _internal_allocmem
mov cs:off_42C090, rcx
lea rcx, _internal_realloc
mov cs:off_42C080, rcx
lea rcx, _internal_free_heaps
mov cs:off_42C088, rcx
mov cs:dword_42C06C, 1
loc_40DEDA: ; CODE XREF: _malloc_init+18↑j
mov cs:dword_42C068, 1
mov rcx, rsi
add rsp, 20h
pop rsi
jmp rax
_malloc_init endp
Далее, если все прошло успешно, выполнение продолжается в функции _internal_malloc
, откуда вызывается __dlmalloc
:
_internal_malloc proc near
test rcx, rcx
jz short loc_41234A
jmp __dlmalloc
...
При этом __dlmalloc
представляет собой длиннющую функцию, среди дебрей которой можно отыскать строчку call
.
В результате попадаем в функцию
VirtualAlloc proc near
jmp cs:__imp_VirtualAlloc
VirtualAlloc endp
Откуда, как мы видим, выполняется переход на библиотечную функцию __imp_VirtualAlloc
. Ну и заросли непроходимого кода нагенерировал C++Builder!
Думается, с удалением будет попроще. Как бы не так! Операторы delete
и free
в итоге приводят к _free_init
, которая по своему образу похожа на _malloc_init
и для очистки памяти вызывает _internal_free
. Уже из нее вызывается __dlfree
, которая, как и в случае с выделением памяти, длиннющая, словно «Война и мир». В недрах этой функции управление передается VirualFree
, которая выглядит вот так:
VirtualFree proc near
jmp cs:__imp_VirtualFree
VirtualFree endp
Чтобы взломать сеть Wi-Fi с помощью Kali Linux, вам нужна беспроводная карта, поддерживающая режим мониторинга…
Работа с консолью считается более эффективной, чем работа с графическим интерфейсом по нескольким причинам.Во-первых, ввод…
Конечно, вы также можете приобрести подписку на соответствующую услугу, но наличие SSH-доступа к компьютеру с…
С тех пор как ChatGPT вышел на арену, возросла потребность в поддержке чата на базе…
Если вы когда-нибудь окажетесь в ситуации, когда вам нужно взглянуть на спектр беспроводной связи, будь…
Elastic Security стремится превзойти противников в инновациях и обеспечить защиту от новейших технологий злоумышленников. В…