Принцип работы перераспределения и освобождения. Как работают обычные умные указатели в 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] ; blockcall operator delete(void *,unsigned __int64) В данном случае IDA без замешательств распознала delete.
Кроме того, удаление ничего не возвращает, но сколько функций делают то же самое? Единственная подсказка заключается в том, что вызов удаления следует за вызовом деструктора (если есть), но поскольку конструктор идентифицируется как функция перед удалением, он создает порочный круг!
Осталось только проанализировать содержимое функции: delete рано или поздно вызывает HeapFree (хотя здесь тоже есть варианты: например, Borland / Embarcadero содержит библиотеки, которые работают с кучей на низком уровне и освободите память, вызвав VirtualFree). К счастью, IDA Pro распознает удаление в большинстве случаев, и вам не нужно напрягаться.
А что произойдет, если IDA не распознает delete? Код будет выглядеть прщзимерно так:
mov rcx, [rsp+58h+block] ; blockcall XXXcmp [rsp+58h+block], 0jnz 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 -28hresult = std::unique_ptr<MyClass,std::default_delete<MyClass> > ptr -20hvar_18 = std::unique_ptr<MyClass,std::default_delete<MyClass> > ptr -18hvar_10 = qword ptr -10hsub rsp, 48hmov rax, cs:__security_cookiexor rax, rspmov [rsp+48h+var_10], raxmov edx, 8 ; unsigned __int64lea rcx, [rsp+48h+var_18] ; thiscall std::unique_ptr<MyClass,std::default_delete<MyClass>>::__autoclassinit2(unsigned __int64)lea rcx, [rsp+48h+result] ; resultcall std::make_unique<MyClass,,0>(void)mov [rsp+48h+_Right], raxmov rdx, [rsp+48h+_Right] ; _Rightlea rcx, [rsp+48h+var_18] ; thiscall std::unique_ptr<MyClass,std::default_delete<MyClass>>::unique_ptr<MyClass,std::default_delete<MyClass>>(std::unique_ptr<MyClass,std::default_delete<MyClass>> &&)noplea rcx, [rsp+48h+result] ; thiscall std::unique_ptr<MyClass,std::default_delete<MyClass>>::~unique_ptr<MyClass,std::default_delete<MyClass>>(void)lea rcx, [rsp+48h+var_18] ; thiscall std::unique_ptr<MyClass,std::default_delete<MyClass>>::operator->(void)mov rcx, rax ; thiscall MyClass::print(void)mov rcx, cs:std::basic_istream<char,std::char_traits<char>> std::cincall cs:std::basic_istream<char,std::char_traits<char>>::get(void)noplea rcx, [rsp+48h+var_18] ; thiscall std::unique_ptr<MyClass,std::default_delete<MyClass>>::~unique_ptr<MyClass,std::default_delete<MyClass>>(void)xor eax, eaxmov rcx, [rsp+48h+var_10]xor rcx, rsp ; StackCookiecall __security_check_cookieadd rsp, 48hretnmain 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], rcxsub rsp, 58hmov [rsp+58h+var_38], 0mov ecx, 1 ; sizecall 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 neararg_0 = qword ptr 8mov [rsp+arg_0], rcxsub rsp, 28hmov rax, [rsp+28h+arg_0]cmp qword ptr [rax], 0jz short loc_140001131mov rax, [rsp+28h+arg_0]mov rcx, rax ; thiscall std::forward<MyClass *>(MyClass * &)mov rcx, [rsp+28h+arg_0]mov rdx, [rcx] ; _Ptrmov rcx, rax ; thiscall std::default_delete<MyClass>::operator()(MyClass *)loc_140001131:add rsp, 28hretnВ начале функции выполняется проверка: cmp , то есть, если указатель на объект нулевой, не имеет смысла его удалять. В ином случае его удаляет библиотечная функция std::, она работает именно с уникальными указателями (в стандарте, кроме них, присутствуют еще shared_ptr), внутри нее происходит вызов оператора delete:
public: void std::default_delete<class MyClass>::operator()(class MyClass *)const proc nearblock = qword ptr -18hvar_10 = qword ptr -10harg_0 = qword ptr 8arg_8 = qword ptr 10hmov [rsp+arg_8], rdxmov [rsp+arg_0], rcxsub rsp, 38hmov rax, [rsp+38h+arg_8]mov [rsp+38h+block], raxmov edx, 1 ; __formalmov rcx, [rsp+38h+block] ; blockcall 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 -34hvar_30 = qword ptr -30hvar_18 = byte ptr -18hvar_10 = qword ptr -10hvar_8 = dword ptr -8var_4 = dword ptr -4arg_2C = dword ptr 34harg_30 = qword ptr 38harg_38 = byte ptr 40hsub rsp, 58hmov [rsp+58h+var_4], 0mov [rsp+58h+var_8], ecxmov [rsp+58h+var_10], rdxlea rdx, [rsp+58h+var_18]mov rcx, rdxmov [rsp+58h+var_30], rdxcall 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, raxcall MyClass::print(void)jmp short $+2loc_40137B:lea rcx, qword_432698call std::istream::get(void)mov [rsp+58h+var_34], eaxjmp short $+2loc_40138D:lea rcx, [rsp+58h+var_18]mov [rsp+58h+var_4], 0call std::unique_ptr<MyClass,std::default_delete<MyClass>>::~unique_ptr()jmp short loc_4013BAlea rcx, [rsp+arg_38]mov r8d, edxmov [rsp+arg_30], raxmov [rsp+arg_2C], r8dcall std::unique_ptr<MyClass,std::default_delete<MyClass>>::~unique_ptr()jmp short loc_4013C3loc_4013BA:mov eax, [rsp+58h+var_4]add rsp, 58hretnloc_4013C3:mov rcx, [rsp+arg_30]call _Unwind_Resumeud2off_4013CF dq offset unk_4290DCmain 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 nearvar_C = dword ptr -0Chvar_8 = qword ptr -8sub rsp, 38hmov [rsp+38h+var_8], rcxmov rcx, [rsp+38h+var_8]xor eax, eaxmov edx, eaxcall std::_Unique_ptr_base<MyClass,std::default_delete<MyClass>>::_Unique_ptr_base<MyClass*>(MyClass*)jmp short $+2loc_401559:add rsp, 38hretnmov ecx, edxmov [rsp+38h+var_C], ecxmov rcx, raxcall __clang_call_terminatejmp short loc_401559off_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 rbxsub rsp, 20hmov rbx, rcxjmp short loc_14000184Aloc_14000183B: ; CODE XREF: operator new(unsigned __int64)+22↓jmov rcx, rbxcall _callnewh_0test eax, eaxjz short loc_14000185Amov rcx, rbx ; Sizeloc_14000184A: ; CODE XREF: operator new(unsigned __int64)+9↑jСюда (в третий блок) выполняется безусловный переход из первого блока кода. Здесь вызывается malloc_0, за которым кроется _imp_malloc. Далее проверяется выделенная память (наполнение регистра RAX). Если память выделена, завершаем функцию. В обратном случае, когда выделить ничего не удалось, прыгаем во второй блок, где с помощью _callnewh_0, за которой также скрывается _imp_malloc, выделяем память в 32-битных условиях, так как далее следует проверка заполнения регистра EAX.
call malloc_0test rax, raxjz short loc_14000183Badd rsp, 20hpop rbxretnloc_14000185A: ; CODE XREF: operator new(unsigned __int64)+15↑jЕсли обе попытки выделить память провалились, управление передается в четвертый блок. А здесь все плохо: в зависимости от состояния регистра RBX могут быть выброшены разные исключения.
cmp rbx, 0FFFFFFFFFFFFFFFFh
jz short loc_140001866call __scrt_throw_std_bad_alloc(void)align 2loc_140001866: ; CODE XREF: operator new(unsigned __int64)+2E↑jcall __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 rsisub rsp, 20hmov rsi, rcxcall __CRTL_MEM_CheckBorMemtest eax, eaxjz short loc_40DE8Amov rax, cs:off_42C078jmp short loc_40DEDAloc_40DE8A: ; CODE XREF: _malloc_init+F↑jlea rax, _internal_freemov cs:off_42C070, raxlea rax, _internal_malloc ; Здесь происходит вызов функцииmov cs:off_42C078, rax ; для выделения памятиlea rcx, _internal_allocmemmov cs:off_42C090, rcxlea rcx, _internal_reallocmov cs:off_42C080, rcxlea rcx, _internal_free_heapsmov cs:off_42C088, rcxmov cs:dword_42C06C, 1loc_40DEDA: ; CODE XREF: _malloc_init+18↑jmov cs:dword_42C068, 1mov rcx, rsiadd rsp, 20hpop rsijmp rax_malloc_init endp Далее, если все прошло успешно, выполнение продолжается в функции _internal_malloc, откуда вызывается __dlmalloc:
_internal_malloc proc near
test rcx, rcxjz short loc_41234Ajmp __dlmalloc...При этом __dlmalloc представляет собой длиннющую функцию, среди дебрей которой можно отыскать строчку call .
В результате попадаем в функцию
VirtualAlloc proc nearjmp cs:__imp_VirtualAllocVirtualAlloc endp Откуда, как мы видим, выполняется переход на библиотечную функцию __imp_VirtualAlloc. Ну и заросли непроходимого кода нагенерировал C++Builder!
Думается, с удалением будет попроще. Как бы не так! Операторы delete и free в итоге приводят к _free_init, которая по своему образу похожа на _malloc_init и для очистки памяти вызывает _internal_free. Уже из нее вызывается __dlfree, которая, как и в случае с выделением памяти, длиннющая, словно «Война и мир». В недрах этой функции управление передается VirualFree, которая выглядит вот так:
VirtualFree proc near jmp cs:__imp_VirtualFreeVirtualFree endpЧтобы взломать сеть Wi-Fi с помощью Kali Linux, вам нужна беспроводная карта, поддерживающая режим мониторинга…
Работа с консолью считается более эффективной, чем работа с графическим интерфейсом по нескольким причинам.Во-первых, ввод…
Конечно, вы также можете приобрести подписку на соответствующую услугу, но наличие SSH-доступа к компьютеру с…
С тех пор как ChatGPT вышел на арену, возросла потребность в поддержке чата на базе…
Если вы когда-нибудь окажетесь в ситуации, когда вам нужно взглянуть на спектр беспроводной связи, будь…
Elastic Security стремится превзойти противников в инновациях и обеспечить защиту от новейших технологий злоумышленников. В…