Фундаментальные основы хакерства. Какие бывают виртуальные функции и как их искать

В своем бестселлере «Фундаментальные основы хакерства», увидевшем свет более 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! Подробнее

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