Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
В своем бестселлере «Фундаментальные основы хакерства», увидевшем свет более 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