В своем бестселлере «Фундаментальные основы хакерства», увидевшем свет более 15 лет назад, Крис Касперски поделился с читателями секретами дизассемблирования и исследования программ. Мы продолжаем публиковать отрывки из обновленного издания его книги. Сегодня мы поговорим о виртуальных функциях, их особенностях и о хитростях, которые помогут отыскать их в коде.
Читай также:
Если функция объявляется в базовом, а реализуется в производном классе, она называется чисто виртуальной функцией, а класс, содержащий хотя бы одну такую функцию, — абстрактным классом. Язык C++ запрещает создание экземпляров абстрактного класса, да и как они могут создаваться, если по крайней мере одна из функций класса не определена?
В стародавние времена компилятор в виртуальной таблице замещал вызов чисто виртуальной функции указателем на библиотечную функцию purecall, потому что на стадии компиляции программы он не мог гарантированно отловить все попытки вызова чисто виртуальных функций. И если такой вызов происходил, управление получала заранее подставленная сюда purecall, которая «ругалась» на запрет вызова чисто виртуальных функций и завершала работу приложения.
Однако в современных реалиях дело обстоит иначе. Компилятор отлавливает вызовы чисто виртуальных функций и банит их во время компиляции. Таким образом, он даже не создает таблицы виртуальных методов для абстрактных классов.
В этом нам поможет убедиться следующий пример (листинг примера PureCall):
#include <stdio.h> class Base { public: virtual void demo(void) = 0; }; class Derived :public Base { public: virtual void demo(void) { printf("DERIVEDn"); } }; int main() { Base *p = new Derived; p->demo(); delete p; // Хотя статья не о том, как писать код на C++, // будем правильными до конца }
Результат его компиляции в общем случае должен выглядеть так:
main proc near push rbx sub rsp, 20h mov ecx, 8 ; size ; Выделение памяти для нового экземпляра объекта call operator new(unsigned __int64) mov rbx, rax lea rax, const Derived::`vftable' mov rcx, rbx ; this mov [rbx], rax ; Вызов метода call cs:const Derived::`vftable' mov edx, 8 ; __formal mov rcx, rbx ; block ; Очищаем выделенную память, попросту удаляем объект call operator delete(void *,unsigned __int64) xor eax, eax add rsp, 20h pop rbx retn main endp
Чтобы узнать, какой метод вызывается инструкцией call cs:const Derived::'vftable'
, надо сначала перейти в таблицу виртуальных методов класса Derived
(нажав Enter):
const Derived::`vftable' dq offset Derived::demo(void)
а отсюда уже в сам метод:
public: virtual void Derived::demo(void) proc near lea rcx, _Format ; "DERIVEDn" jmp printf public: virtual void Derived::demo(void) endp
В дизассемблерном листинге для x86 IDA сразу подставляет правильное имя вызываемого метода:
call Derived::demo(void)
Это мы выяснили. И никакого намека на purecall.
Хочу также обратить твое внимание на следующую деталь. Старые компиляторы вставляли код проверки и обработки ошибок выделения памяти непосредственно после операции выделения памяти, тогда как современные компиляторы перенесли эту заботу внутрь оператора new
:
void * operator new(unsigned __int64) proc near push rbx sub rsp, 20h mov rbx, rcx jmp short loc_14000110E ; После пролога выполняется безусловный переход loc_1400010FF: mov rcx, rbx call _callnewh_0 ; Вторая попытка выделения памяти test eax, eax jz short loc_14000111E ; Если память снова не удалось выделить, ; переходим в конец функции, где вызываем функции ; обработки ошибок mov rcx, rbx ; Size loc_14000110E: call malloc_0 ; Первая попытка выделения памяти test rax, rax ; Проверка успешности выделения памяти jz short loc_1400010FF ; Если rax == 0, значит, произошла ошибка и память не ; выделена, тогда совершаем переход и делаем еще попытку add rsp, 20h pop rbx retn loc_14000111E: cmp rbx, 0FFFFFFFFFFFFFFFFh jz short loc_14000112A call __scrt_throw_std_bad_alloc(void) align 2 loc_14000112A: call __scrt_throw_std_bad_array_new_length(void) align 10h void * operator new(unsigned __int64) endp
После пролога функции командой jmp short loc_14000110E
выполняется безусловный переход на код для выделения памяти: call malloc_0
. Проверяем результат операции: test rax, rax
. Если выделение памяти провалилось, переходим на метку jz short loc_1400010FF
, где еще раз пытаемся зарезервировать память:
mov rcx, rbx call _callnewh_0 test eax, eax
Если эта попытка тоже проваливается, нам ничего не остается, как перейти по метке jz short loc_14000111E
, обработать ошибки и вывести соответствующее ругательство.
Сколько бы экземпляров класса (другими словами, объектов) ни существовало, все они пользуются одной и той же виртуальной таблицей. Виртуальная таблица принадлежит самому классу, но не экземпляру (экземплярам) этого класса. Впрочем, из этого правила существуют исключения.
Для демонстрации совместного использования одной копии виртуальной таблицы несколькими экземплярами класса рассмотрим следующий пример (листинг примера UsingVT):
#include <stdio.h> class Base { public: virtual void demo() { printf("Basen"); } }; class Derived : public Base { public: virtual void demo() { printf("Derivedn"); } }; int main() { Base *obj1 = new Derived; Base *obj2 = new Derived; obj1->demo(); obj2->demo(); delete obj1; delete obj2; }
Результат его компиляции в общем случае должен выглядеть так:
main proc near mov [rsp+arg_0], rbx mov [rsp+arg_8], rsi push rdi sub rsp, 20h mov ecx, 8 ; size call operator new(unsigned __int64) ; Выделяем память под первый экземпляр класса lea rsi, const Derived::`vftable' ; В созданный объект копируем виртуальную таблицу ; класса Derived mov ecx, 8 ; size mov rdi, rax mov [rax], rsi ; RAX теперь указывает на первый экземпляр call operator new(unsigned __int64) ; Выделяем память под второй экземпляр класса mov rcx, rdi ; В RDI — указатель на виртуальную таблицу класса Derived (см. выше) mov rbx, rax mov [rax], rsi ; В RSI находится первый объект mov r8, [rdi] ; Берем указатель на виртуальную таблицу методов call qword ptr [r8] ; Для первого объекта, скопированного в RAX, вызываем метод ; по указателю в виртуальной таблице mov r8, [rbx] ; В RBX — указатель на виртуальную таблицу класса Derived mov rcx, rbx call qword ptr [r8] ; Вызываем метод по указателю в этой же самой виртуальной таблице mov edx, 8 ; __formal mov rcx, rdi ; block call operator delete(void *,unsigned __int64) mov edx, 8 ; __formal mov rcx, rbx ; block call operator delete(void *,unsigned __int64) mov rbx, [rsp+28h+arg_0] xor eax, eax mov rsi, [rsp+28h+arg_8] add rsp, 20h pop rdi retn main endp
Виртуальная таблица класса Derived
выглядит так:
const Derived::`vftable' dq offset Derived::demo(void), 0
Обрати внимание: виртуальная таблица одна на все экземпляры класса.
Окей, для успешной работы, понятное дело, вполне достаточно и одной виртуальной таблицы, однако на практике приходится сталкиваться с тем, что исследуемый файл прямо-таки кишит копиями этих виртуальных таблиц. Что же это за напасть такая, откуда она берется и как с ней бороться?
Если программа состоит из нескольких файлов, компилируемых в самостоятельные obj-модули (а такой подход используется практически во всех мало-мальски серьезных проектах), компилятор, очевидно, должен поместить в каждый obj свою собственную виртуальную таблицу для каждого используемого модулем класса. В самом деле, откуда компилятору знать о существовании других obj и наличии в них виртуальных таблиц?
Вот так и возникают никому не нужные дубли, отъедающие память и затрудняющие анализ. Правда, на этапе компоновки линкер может обнаружить копии и удалить их, да и сами компиляторы используют различные эвристические приемы для повышения эффективности генерируемого кода. Наибольшую популярность завоевал следующий алгоритм: виртуальная таблица помещается в тот модуль, в котором содержится реализация первой невстроенной невиртуальной функции класса.
Обычно каждый класс реализуется в одном модуле, и в большинстве случаев такая эвристика срабатывает. Хуже, если класс состоит из одних виртуальных или встраиваемых функций. В этом случае компилятор «ложится» и начинает запихивать виртуальные таблицы во все модули, где этот класс используется. Последняя надежда на удаление «мусорных» копий — линкер, но и он не панацея. Собственно, эти проблемы должны больше заботить разработчиков программы (если их волнует, сколько памяти занимает программа), для анализа лишние копии всего лишь досадная помеха, но отнюдь не непреодолимое препятствие!
В большинстве случаев виртуальная таблица — это обыкновенный массив, но некоторые компиляторы представляют ее в виде связанного списка. Каждый элемент виртуальной таблицы содержит указатель на следующий элемент, а сами элементы не размещены вплотную друг к другу, а рассеяны по всему исполняемому файлу.
На практике подобное, однако, попадается крайне редко, поэтому не будем подробно на этом останавливаться — достаточно лишь знать, что такое бывает. Если ты встретишься со списками (впрочем, это вряд ли) — разберешься по обстоятельствам, благо это несложно.
Будь также готов и к тому, чтобы встретить в виртуальной таблице указатель не на виртуальную функцию, а на код, который модифицирует этот указатель, занося в него смещение вызываемой функции. Этот прием был предложен самим разработчиком языка C++ Бьерном Страуструпом, позаимствовавшим его из ранних реализаций алгола-60. В алголе код, корректирующий указатель вызываемой функции, называется шлюзом (thunk), а сам вызов — вызовом через шлюз. Вполне справедливо употреблять эту терминологию и по отношению к C++.
Однако в настоящее время вызов через шлюз чрезвычайно мало распространен и не используется практически ни одним компилятором. Несмотря на то что он обеспечивает более компактное хранение виртуальных таблиц, модификация указателя приводит к излишним накладным расходам на процессорах с конвейерной архитектурой (а все современные процессоры как раз и построены на основе такой архитектуры). Поэтому использование шлюзовых вызовов оправданно лишь в программах, критических к размеру, но не к скорости.
Подробнее обо всем этом можно прочесть в руководстве по алголу-60 (шутка) или у Бьерна Страуструпа в «Дизайне и эволюции языка C++».
Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», увеличит личную накопительную скидку и позволит накапливать профессиональный рейтинг Xakep Score! Подробнее
1 год7690 р. |
1 месяц720 р. |
Я уже участник «Xakep.ru»
Читайте также
Последние новости