В своем бестселлере «Фундаментальные основы хакерства», увидевшем свет более 15 лет назад, Крис Касперски поделился с читателями секретами дизассемблирования и исследования программ. Мы продолжаем публиковать отрывки из обновленного издания его книги. Сегодня мы поговорим о виртуальных функциях, их особенностях и о хитростях, которые помогут отыскать их в коде.
Читай также:
- Проверка аутентичности и базовый взлом защиты
- Знакомство с отладчиком
- Продолжаем осваивать отладчик
- Новые способы находить защитные механизмы в чужих программах
- Выбираем лучший редактор для вскрытия исполняемых файлов Windows
- Мастер-класс по анализу исполняемых файлов в IDA Pro
- Учимся искать ключевые структуры языков высокого уровня
- Идентификация стартового кода и виртуальных функций приложений под Win64
Идентификация чисто виртуальных функций
Если функция объявляется в базовом, а реализуется в производном классе, она называется чисто виртуальной функцией, а класс, содержащий хотя бы одну такую функцию, — абстрактным классом. Язык C++ запрещает создание экземпляров абстрактного класса, да и как они могут создаваться, если по крайней мере одна из функций класса не определена?
В стародавние времена компилятор в виртуальной таблице замещал вызов чисто виртуальной функции указателем на библиотечную функцию purecall, потому что на стадии компиляции программы он не мог гарантированно отловить все попытки вызова чисто виртуальных функций. И если такой вызов происходил, управление получала заранее подставленная сюда purecall, которая «ругалась» на запрет вызова чисто виртуальных функций и завершала работу приложения.
Однако в современных реалиях дело обстоит иначе. Компилятор отлавливает вызовы чисто виртуальных функций и банит их во время компиляции. Таким образом, он даже не создает таблицы виртуальных методов для абстрактных классов.
Реализация вызова виртуальных функций
В этом нам поможет убедиться следующий пример (листинг примера PureCall):
#include 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++, // будем правильными до конца } [/code]
Результат его компиляции в общем случае должен выглядеть так:
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 [/code]
В дизассемблерном листинге для 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 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; } [/code]
Результат его компиляции в общем случае должен выглядеть так:
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++».
Перейти обратно к новости
|