В своем бестселлере «Фундаментальные основы хакерства», увидевшем свет более 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("DERIVED\n");
  }
};

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    ; "DERIVED\n"
        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("Base\n");
  }
};

class Derived : public Base {
  public:
  virtual void demo()
  {
    printf("Derived\n");
  }
};

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)
  ; В созданный объект копируем виртуальную таблицу класса Derived
  lea      rsi, const Derived::`vftable'
  mov      ecx, 8 ; size
  mov      rdi, rax
  ; RAX теперь указывает на первый экземпляр
  mov      [rax], rsi
  ; Выделяем память под второй экземпляр класса
  call     operator new(unsigned __int64)
  ; В RDI — указатель на виртуальную таблицу класса Derived
  mov      rcx, rdi
  mov      rbx, rax
  ; В RSI находится первый объект
  mov      [rax], rsi
  ; Берем указатель на виртуальную таблицу методов
  mov      r8, [rdi]
  ; Для первого объекта, скопированного в RAX, вызываем метод
  ; по указателю в виртуальной таблице
  call     qword ptr [r8]
  ; В RBX — указатель на виртуальную таблицу класса Derived
  mov      r8, [rbx]
  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.ru»!

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», увеличит личную накопительную скидку и позволит накапливать профессиональный рейтинг Xakep Score! Подробнее

Крис Касперски

Крис Касперски

Известный российский хакер. Легенда ][, ex-редактор ВЗЛОМа. Также известен под псевдонимами мыщъх, nezumi (яп. 鼠, мышь), n2k, elraton, souriz, tikus, muss, farah, jardon, KPNC.

Юрий Язев

Юрий Язев

Широко известен под псевдонимом yurembo. Программист, разработчик видеоигр, независимый исследователь. Старый автор журнала «Хакер».

Check Also

Хитрости BIND. Берем контроль над DNS в свои руки

BIND был и остается одной из самых популярных реализаций сервера DNS. Долгое время он был …

Оставить мнение