Категория > Новости > Фундаментальные основы хакерства. Какие бывают виртуальные функции и как их искать - «Новости»

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


15-06-2020, 12:38. Автор: Martin
В своем бестселлере «Фундаментальные основы хакерства», увидевшем свет более 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++».



Перейти обратно к новости