Как работает выделение и освобождение памяти? Каким образом устроены обычные и умные указатели в C++? Как распознать операторы работы с памятью, используя дизассемблер, не понимающий их истинную природу? Чтобы во всем этом разобраться, нам предстоит по байтикам разобрать механизмы распределения динамической памяти приложения (иными словами, кучи) двух самых популярных компиляторов и выявить различия в их работе. Поэтому в статье нас ждет множество дизассемблерных листингов и кода на C++.
Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Редакторы «Хакера» попытались обновить этот объемный труд и перенести его из времен Windows 2000 и Visual Studio 6.0 во времена Windows 10 и Visual Studio 2019.
Ссылки на другие статьи из этого цикла ищи на странице автора.
Указатель this
— это настоящий золотой ключик или, если угодно, спасательный круг, позволяющий не утонуть в бурном океане ООП. Именно благодаря this
можно определять принадлежность вызываемой функции к тому или иному классу. Поскольку все невиртуальные функции объекта вызываются непосредственно — по фактическому адресу, объект как бы расщепляется на составляющие его функции еще на стадии компиляции. Не будь указателей this
, восстановить иерархию функций было бы принципиально невозможно!
Таким образом, правильная идентификация this
очень важна. Единственная проблема: как отличить его от указателей на массивы и структуры? Ведь экземпляр класса идентифицируется по указателю this
(если на выделенную память указывает this
, это экземпляр класса), однако сам this
по определению — это указатель, ссылающийся на экземпляр класса. Замкнутый круг! К счастью, есть одна лазейка... Код, манипулирующий указателем this
, весьма специфичен, что и позволяет отличить this
от всех остальных указателей.
Вообще‑то у каждого компилятора свой почерк, который настоятельно рекомендуется изучить, дизассемблируя собственные программы на C++, но существуют и универсальные рекомендации, применимые к большинству реализаций. Поскольку this
— это неявный аргумент каждой функции — члена класса, то логично отложить разговор о его идентификации до раздела «Идентификация аргументов функций». Здесь же мы обсудим, как реализуют передачу указателя this
самые популярные компиляторы.
Здесь мы, конечно, говорим об архитектуре x64. На 32-битной платформе параметры, выровненные до 32-битного размера, передаются через стек. С другой стороны, на 64-битной платформе дела обстоят интереснее: первые четыре целочисленных аргумента передаются в регистрах RCX
, RDX
, R8
, R9
. Если целочисленных аргументов больше, остальные размещаются в стеке. Аргументы, имеющие значения с плавающей запятой, передаются в регистрах XMM0
, XMM1
, XMM2
, XMM3
. При этом 16-битные аргументы передаются по ссылке. Замечу, все это касается соглашения о вызовах в операционных системах Microsoft (Microsoft ABI), в Unix-подобных системах дела обстоят по‑другому. Но не будем распылять на них свое внимание.
Оба протестированных мною компилятора, Visual C++ 2019 и C++ Builder 10.3, независимо от соглашения вызова функции (__cdecl
, __clrcall
, __stdcall
, __fastcall
, __thiscall
) передают указатель this
в регистре RCX
, что соответствует его природе: this
— целочисленный аргумент.
Операторы new
и delete
транслируются компилятором в вызовы библиотечных функций, которые могут быть распознаны точно так же, как и обычные библиотечные функции. Автоматически распознавать библиотечные функции умеет, в частности, IDA Pro, снимая эту заботу с плеч исследователя. Однако IDA Pro есть не у всех и далеко не всегда в нужный момент находится под рукой, да к тому же не все библиотечные функции она знает, а из тех, что знает, не всегда узнает new
и delete
... Словом, причин идентифицировать их вручную предостаточно.
Реализация new
и delete
может быть любой, но Windows-компиляторы в большинстве своем редко реализуют функции работы с кучей самостоятельно. Зачем это? Намного проще обратиться к услугам операционной системы. Однако наивно ожидать вместо new
появление вызова HeapAlloc
, а вместо delete
— HeapFree
. Нет, компилятор не так прост! Разве он может отказать себе в удовольствии «вырезания матрешек»? Оператор new
транслируется в функцию new
, вызывающую для выделения памяти malloc
, malloc
же, в свою очередь, обращается к HeapAlloc
(или ее подобию — в зависимости от реализации библиотеки работы с памятью) — своеобразной «обертке» одноименной Win32 API-процедуры. Картина с освобождением памяти аналогична.
Углубляться в дебри вложенных вызовов слишком утомительно. Нельзя ли new
и delete
идентифицировать как‑нибудь иначе, с меньшими трудозатратами и без лишней головной боли? Разумеется, можно! Давай вспомним все, что мы знаем о new:
если объект не содержит ни данных, ни виртуальных функций, его размер равен единице (минимальный блок памяти, выделяемый только для того, чтобы было на что указывать указателю this
); отсюда будет очень много вызовов типа
mov ecx, 1 ; sizecall XXX
где XXX
и есть адрес new
! Вообще же, типичный размер объектов составляет менее сотни байтов... ищи часто вызываемую функцию с аргументом‑константой меньше ста байтов;
функция new
— одна из самых популярных библиотечных функций, ищи функцию с «толпой» перекрестных ссылок;
самое характерное: new
возвращает указатель this
, а this
очень легко идентифицировать даже при беглом просмотре кода (обычно он возвращается в регистре RCX
);
возвращенный new
результат всегда проверяется на равенство нулю (операторами типа test RCX
, 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
ничего не возвращает, но мало ли функций поступают точно так же? Единственная зацепка — вызов delete
следует за вызовом деструктора (если он есть), но, поскольку конструктор как раз и идентифицируется как функция, предшествующая delete
, образуется замкнутый круг!
Ничего не остается, кроме как анализировать содержимое функции: delete
рано или поздно вызывает HeapFree
(хотя тут возможны и варианты: так, Borland/Embarcadero содержит библиотеки, работающие с кучей на низком уровне и освобождающие память вызовом VirtualFree
). К счастью, IDA Pro в большинстве случаев опознает delete
и самостоятельно напрягаться не приходится.
А что произойдет, если IDA не распознает delete
? Код будет выглядеть примерно так:
mov rcx, [rsp+58h+block] ; blockcall XXXcmp [rsp+58h+block], 0jnz short loc_1400010B0
Неглубокий анализ показывает: в первой строчке в регистр RCX
, очевидно для передачи в качестве параметра, помещается блок памяти. Похоже, это указатель на сущность. А после вызова XXX
выполняется сравнение этого блока памяти с нулем и, если блок не обнулен, происходит переход по адресу. Таким несложным образом мы можем легко идентифицировать delete
, даже если IDA его не определяет.
Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
1 год7690 р. |
1 месяц720 р. |
Я уже участник «Xakep.ru»
Читайте также
Последние новости