Следующая новость
Предыдущая новость

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

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

Содержание статьи

  • Идентификация чисто виртуальных функций
  • Совместное использование виртуальной таблицы несколькими экземплярами класса
  • Копии виртуальных таблиц
  • Связанный список
  • Вызов через шлюз
  • Сложный пример наследования
  • Переименование инструкций
  • На заметку
  • Статические объекты
  • Идентификация виртуальных таблиц
  • Второй вариант инициализации массива
  • Заключение

В своем бестселлере «Фундаментальные основы хакерства», увидевшем свет более 15 лет назад, Крис Касперски поделился с читателями секретами дизассемблирования и исследования программ. Мы продолжаем публиковать отрывки из обновленного издания его книги. Сегодня мы поговорим о виртуальных функциях, их особенностях и о хитростях, которые помогут отыскать их в коде.

Читай также:

  • Проверка аутентичности и базовый взлом защиты
  • Знакомство с отладчиком
  • Продолжаем осваивать отладчик
  • Новые способы находить защитные механизмы в чужих программах
  • Выбираем лучший редактор для вскрытия исполняемых файлов Windows
  • Мастер-класс по анализу исполняемых файлов в IDA Pro
  • Учимся искать ключевые структуры языков высокого уровня
  • Идентификация стартового кода и виртуальных функций приложений под Win64

Идентификация чисто виртуальных функций

Если функция объявляется в базовом, а реализуется в производном классе, она называется чисто виртуальной функцией, а класс, содержащий хотя бы одну такую функцию, — абстрактным классом. Язык 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.ru»!

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

1 год

7690 р.

1 месяц

720 р.

Я уже участник «Xakep.ru»

Источник

Последние новости