Фундаментальные основы хакерства

       

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


А мы летим орбитами, путями неизбитыми,Прошит метеоритами простор.Оправдан риск и мужество, космическая музыка

Вплывает в деловой наш разговор.

"Трава у дома" Земляне

Виртуальная функция по определению обозначает "определяемая по время выполнения программы". При вызове виртуальной функции выполняемый код должен соответствовать динамическому типу объекта, из которого вызывается функция. Поэтому, адрес виртуальной функции не может быть определен на стадии компиляции – это приходится делать непосредственно в момент ее вызова. Вот почему вызов виртуальной функции – всегда косвенный

вызов (исключение составляют лишь виртуальные функции статических объектов, - см. "Статическое связывание").

В то время как не виртуальные функции вызываются в точности так же, как и обычные Си-функции, вызов виртуальных функций кардинально отличается. Конкретная схема зависит от реализации конкретного компилятора, но общем случае ссылки на все виртуальные функции помещаются в специальный массив – виртуальную таблицу (virtual table –

сокращенно VTBL), а в каждый экземпляр объекта, использующий хотя бы одну виртуальную функцию, помещается указатель на виртуальную таблицу (virtual table pointer – сокращенно VPRT). Причем, независимо от числа виртуальный функций, каждый объект имеет только один указатель.

Вызов виртуальных функций всегда происходит косвенно, через ссылку на виртуальную таблицу – например: CALL [EBX+0х10], где EBX

– регистр, содержащий смещение виртуальной таблицы в памяти, а 0x10 – смещение указателя на виртуальную функцию внутри виртуальной таблицы.

Анализ вызова виртуальных функций наталкивается на ряд сложностей, самая коварная из которых, – необходимость обратной трассировки кода для отслеживания значения регистра, используемого для косвенной адресации. Хорошо, если он инициализируется непосредственным значением типа "MOV EBX, offset VTBL" недалеко от места использования, но значительно чаще указатель на VTBL передается функции как неявный аргумент или (что еще хуже) один и тот же указатель используется для вызова двух различных виртуальных функций и возникает неопределенность – какое именно значение (значения) он имеет в данной ветке программы?


Разберем следующий пример ( предварительно вспомнив, что если одна и та же не виртуальная функция присутствует и базовом, и в производном классе – всегда вызывается функция базового класса).

#include <stdio.h>

class Base{

 public:

virtual void demo(void)



{

printf("BASE\n");

};

virtual void demo_2(void)

{

printf("BASE DEMO 2\n");

};

void demo_3(void)

{

printf("Non virtual BASE DEMO 3\n");

};

};

class Derived: public Base{

 public:

virtual void demo(void)

{

printf("DERIVED\n");

};

virtual void demo_2(void)

{

printf("DERIVED DEMO 2\n");

};

void demo_3(void)

{

printf("Non virtual DERIVED DEMO 3\n");

};

};

main()

{

Base *p = new Base;

p->demo();

p->demo_2();

p->demo_3();

p = new Derived;

p->demo();

p->demo_2();

p->demo_3();

}

Листинг 24 Демонстрация вызова виртуальных функций

Результат ее компиляции в общем случае должен выглядеть так:

main   proc near           ; CODE XREF: start+AFp

push   esi

push   4

call   ??2@YAPAXI@Z ; operator new(uint)

; EAX c- указатель на выдел. блок памяти

; Выделяем четыре байта памяти для экземпляра нового объекта.

; Объект состоит из одного лишь указателя на VTBL.

add    esp, 4

test   eax, eax

jz     short loc_0_401019 ; --> Ошибка выделения памяти

; проверка успешности выделения памяти

mov    dword ptr [eax], offset BASE_VTBL

; Вот здесь в только что созданный экземпляр объекта копируется

; указатель на виртуальную таблицу класса BASE.

; То, что это именно виртуальная таблица класса BASE, можно узнать

; проанализировав элементы этой таблицы – они указывают на члены

; класса BASE, следовательно, сама таблица – виртуальная таблица

; класса BASE

mov    esi, eax     ; ESI = **BASE_VTBL

; заносим в ESI указатель на экземпляр объекта (указатель на указатель

; на BASE_VTBL

; Зачем? Дело в том, что на самом деле в ESI



заносится указатель на

; экземпляр объекта (см. "Идентификация объектов, структур и массивов),

; но нам на данном этапе все эти детали ни к чему, поэтому, мы просто

; говорим, что в ESI – указатель на указатель на виртуальную таблицу

; базового класса, не вникая для чего понадобился этот двойной указатель.

jmp    short loc_0_40101B

loc_0_401019:                     ; CODE XREF: sub_0_401000+Dj

xor    esi, esi

; принудительно обнуляем указатель на экземпляр объекта (эта ветка получает управление

; только в случае неудачного выделения памяти для объекта) нулевой указатель

; словит обработчик структурных исключений при первой же попытке обращения

loc_0_40101B:                     ; CODE XREF: sub_0_401000+17j

mov    eax, [esi]          ; EAX = *BASE_VTBL == *BASE_DEMO

; заносим в EAX указатель на виртуальную таблицу класса BASE,

; не забывая о том, что указатель на виртуальную таблицу одновременно

; является указателем и на первый элемент этой таблицы.

; А первый элемент виртуальной таблицы, содержащий указатель

; на первую (в порядке объявления) виртуальную функцию класса.

mov    ecx, esi     ; ECX = this

; заносим в ECX указатель на экземпляр объекта, передавая вызываемой функции

; неявный аргумент – указатель this

(см. "Идентификация аргументов функций")

call   dword ptr [eax]     ; CALL BASE_DEMO

; Вот он – вызов виртуальной функции! Чтобы понять – какая именно функция

; вызывается, мы должны знать значение регистра EAX. Прокручивая экран

; дизассемблера вверх, мы видим – EAX

указывает на BASE_VTBL, а первый

; член BASE_VTBL

(см. ниже) указывает на функцию BASE_DEMO. Следовательно:

; а) этот код вызывает именно функцию BASE_DEMO

; б) функция BASE_DEMO

– это виртуальная

функция

mov    edx, [esi]   ; EDX =      *BASE_DEMO

; заносим в EDX указатель на первый элемент виртуальной таблицы класса BASE

mov    ecx, esi     ; ECX = this

; заносим в ECX указатель на экземпляр объекта



; Это неявный аргумент функции – указатель this

(см. "Идентификация this")

call   dword ptr [edx+4] ; CALL [BASE_VTBL+4] (BASE_DEMO_2)

; Еще один вызов виртуальной функции! Чтобы понять – какая именно функция

; вызывается, мы должны знать содержимое регистра EDX. Прокручивая экран

; дизассемблера вверх, мы видим, что он указывает на BASE_VTBL, а EDX+4,

; стало быть, указывает на второй элемент виртуальной таблицы класса BASE.

; Он же, в свою очередь, указывает на функцию BASE_DEMO_2

push   offset aNonVirtualBase ; "Non virtual BASE DEMO       3\n"

call   printf

; а вот вызов не виртуальной функции. Обратите внимание – он происходит

; как и вызов обычной Си функции. (Обратите внимание, что эта функция -

; встроенная, т.к. объявленная непосредственно в самом классе и вместо ее

; вызова осуществляется подстановка кода)

push   4

call   ??2@YAPAXI@Z ; operator new(uint)

; Далее идет вызов функций класса DERIVED. Не будем здесь подробно

; его комментировать – сделайте это самостоятельно. Вообще же, класс

; DERIVED

понадобился только для того, чтобы показать особенности компоновки

; виртуальных таблиц

add    esp, 8              ; Очистка после     printf

& new

test   eax, eax

jz     short loc_0_40104A ; Ошибка выделения памяти

mov    dword ptr [eax], offset DERIVED_VTBL

mov    esi, eax            ; ESI == **DERIVED_VTBL

jmp    short loc_0_40104C

loc_0_40104A:                     ; CODE XREF: sub_0_401000+3Ej

xor    esi, esi

loc_0_40104C:                     ; CODE XREF: sub_0_401000+48j

mov    eax, [esi]          ; EAX =      *DERIVED_VTBL

mov    ecx, esi            ; ECX = this

call   dword ptr [eax]     ; CALL [DERIVED_VTBL] (DERIVED_DEMO)

mov    edx, [esi]          ; EDX =      *DERIVED_VTBL

mov    ecx, esi            ; ECX=this

call   dword ptr [edx+4]   ; CALL [DERIVED_VTBL+4] (DERIVED_DEMO_2)

push   offset aNonVirtualBase ; "Non virtual BASE DEMO 3\n"



call   printf

; Обратите внимание – вызывается функция BASE_DEMO базового,

; а не производного класса!!!

add    esp, 4

pop    esi

retn  

main   endp

BASE_DEMO    proc near           ; DATA XREF: .rdata:004050B0o

push   offset aBase        ; "BASE\n"

call   printf

pop    ecx

retn  

BASE_DEMO    endp

BASE_DEMO_2  proc near           ; DATA XREF: .rdata:004050B4o

push   offset aBaseDemo2   ; "BASE DEMO 2\n"

call   printf

pop    ecx

retn  

BASE_DEMO_2  endp

DERIVED_DEMO proc near           ; DATA XREF: .rdata:004050A8o

push   offset aDerived     ; "DERIVED\n"

call   printf

pop    ecx

retn  

DERIVED_DEMO endp

DERIVED_DEMO_2      proc near           ; DATA XREF: .rdata:004050ACo

push   offset aDerivedDemo2       ; "DERIVED   DEMO 2\n"

call   printf

pop    ecx

retn  

DERIVED_DEMO_2      endp

DERIVED_VTBL dd offset DERIVED_DEMO     ; DATA XREF: sub_0_401000+40o

dd offset DERIVED_DEMO_2

BASE_VTBL    dd offset BASE_DEMO ; DATA XREF: sub_0_401000+Fo

dd offset BASE_DEMO_2

; Обратите внимание – виртуальные таблицы "растут" снизу вверх в порядке

; объявления классов в программе, а элементы виртуальных таблиц "растут"

; сверху вниз в порядке объявления виртуальных функций в классе.

; Конечно, так бывает не всегда (порядок размещения таблиц и их элементов

; нигде не декларирован и целиком лежит на "совести" компилятора, но на

; практике большинство из них ведут себя именно так) Сами же виртуальные

; функции располагаются вплотную друг к другу в порядке их объявления

Листинг 25



Рисунок 11 0x006 Художнику – добавить функции A, B и С  Реализация вызова виртуальных функций

::идентификация чистой виртуальной функции. Если функция объявляется в базовом, а реализуется в производным классе – такая функция называется чистой виртуальной функцией, а класс, содержащий хотя бы одну такую функцию, называется абстрактным классом.


Язык Си++ запрещает создание экземпляров абстрактного класса, да и как они могут создаваться, если, по крайней мере, одна из функций класса неопределенна?

На первый взгляд – не определена, и ладно, – какая в этом беда? Ведь на анализ программы это не влияет. На самом деле это не так – чистая виртуальная функция в виртуальной таблице замещается указателем на библиотечную функцию __purecall. Зачем она нужна? Дело в том, что на стадии компиляции программы невозможно гарантированно "отловить" все попытки вызова чисто виртуальных функций, но если такой вызов и произойдет, управление получит заранее подставленная сюда __purecall, которая выведет на экран "ругательство" по поводу запрета на вызов чисто виртуальных функций и завершит работу приложения. Подробнее об этом можно прочитать в технической заметке MSNDN

Q120919, датированной 27 июня 1997 года.

Таким образом, встретив в виртуальной таблице указатель на __purecall, можно с уверенностью утверждать, что мы имеем дело с чисто виртуальной функцией. Рассмотрим следующий пример:

#include <stdio.h>

class Base{

 public:

virtual void demo(void)=0;

};

class Derived:public Base {

 public:

virtual void demo(void)

{

printf("DERIVED\n");

};

};

main()

{

Base *p = new Derived;

p->demo();

}

Листинг 26 Демонстрация вызова чистой виртуальной функции

Результат его компиляции в общем случае должен выглядеть так:

main         proc near           ; CODE XREF: start+AFp

push   4

call   ??2@YAPAXI@Z

add    esp, 4

; Выделение памяти для нового экземляра объекта

test   eax, eax

; Проверка успешности выделения памяти

jz     short loc_0_401017

mov    ecx, eax

; ECX = this

call   GetDERIVED_VTBL

; занесение в экземпляр объекта указателя на виртуальную таблицу класса

; DERIVED

jmp    short loc_0_401019

loc_0_401017:                     ; CODE XREF: main+Cj

xor    eax, eax

; EAX = NULL



loc_0_401019:                     ; CODE XREF: main+15j

mov    edx, [eax]

; тут возникает исключение по обращению к нулевому указателю

mov    ecx, eax

jmp    dword ptr [edx]

main         endp

GetDERIVED_VTBL     proc near           ; CODE XREF: main+10p

push   esi

mov    esi, ecx

; Через регистр ECX функции передается неявный аргумент – this

call   SetPointToPure

; функция заносит в экземпляр объекта указатель на __purecall

; специальную функцию - заглушку на случай незапланированного вызова

; чисто виртуальной функции

mov    dword ptr [esi], offset DERIVED_VTBL

; занесение в экземпляр объекта указателя на виртуальную таблицу производного

; класса, с затиранием предыдущего значения (указателя на __purecall)

mov    eax, esi

pop    esi

retn  

GetDERIVED_VTBL     endp

DERIVED_DEMO proc near           ; DATA XREF: .rdata:004050A8o

push   offset aDerived     ; "DERIVED\n"

call   printf

pop    ecx

retn  

DERIVED_DEMO endp

SetPointToPure      proc near           ; CODE XREF: GetDERIVED_VTBL+3p

mov    eax, ecx

mov    dword ptr [eax], offset PureFunc

; Заносим по [EAX] (в экземляр нового объекта) указатель на специальную

; функцию - __purecall, которая предназначена для отслеживания попыток

; вызова чисто виртуальной функции в ходе выполнения программы -

; если такая попытка произойдет, __purecall выведет на экран "матюгательство"

; дескать, вызывать чисто виртуальную функцию нельзя и завершит работу

retn  

SetPointToPure      endp

DERIVED_VTBL dd offset DERIVED_DEMO     ; DATA XREF: GetDERIVED_VTBL+8o

PureFunc     dd offset __purecall       ; DATA XREF: SetPointToPure+2o

; указатель на функцию-заглушку __purecall. Следовательно, мы имеем дело

; с чисто виртуальной функцией

Листинг 27

::совместное использование виртуальной таблицы несколькими экземплярами объекта. Сколько бы экземпляров объекта ни существовало – все они пользуются одной и той же виртуальной таблицей.


Виртуальная таблица принадлежит самому объекту, но не экземпляру (экземплярам) этого объекта. Впрочем, из этого правила существуют и исключения (см. "Копии виртуальных таблиц").



Рисунок 12 0x007 все экземпляры объекта используют одну и ту же виртуальную таблицу

Для подтверждения сказанного рассмотрим следующий пример:

#include <stdio.h>

class Base{

 public:

virtual demo ()

{

printf("Base\n");

}

};

class Derived:public Base{

 public:

virtual demo()

{

printf("Derived\n");

}

};

main()

{

Base * obj1 = new Derived;

Base * obj2 = new Derived;

obj1->demo();

obj2->demo();

}

Листинг 28 Демонстрация совместного использование одной копии виртуальной таблицы несколькими экземплярами класса

Результат его компиляции в общем случае должен выглядеть так:

main         proc near           ; CODE XREF: start+AFp

push   esi

push   edi

push   4

call   ??2@YAPAXI@Z ; operator new(uint)

add    esp, 4

; выделяем память под первый экземпляр объекта

test   eax, eax

jz     short loc_0_40101B

mov    ecx, eax            ; EAX

– указывает на первый экземпляр объекта

call   GetDERIVED_VTBL

; в EAX – указатель на виртуальную таблицу класса DERIVED

mov    edi, eax            ; EDI = *DERIVED_VTBL

jmp    short loc_0_40101D

loc_0_40101B:                     ; CODE XREF: main+Ej

xor    edi, edi

loc_0_40101D:                     ; CODE XREF: main+19j

push   4

call   ??2@YAPAXI@Z ; operator new(uint)

add    esp, 4

; выделяем память под второй экземпляр объекта

test   eax, eax

jz     short loc_0_401043

mov    ecx, eax            ; ECX – this

call   GetDERIVED_VTBL

; обратите внимание – второй экземпляр использует ту же самую

; виртуальную

таблицу

DERIVED_VTBL dd offset DERIVED_DEMO     ; DATA XREF: GetDERIVED_VTBL+8o

BASE_VTBL    dd offset BASE_DEMO ; DATA XREF: GetBASE_VTBL+2o



; Обратите внимание – виртуальная таблица одна на все экземпляры класса

Листинг 29

::копии виртуальных таблиц. ОК, для успешной работы, - понятное дело, - вполне достаточно и одной виртуальной таблицы, однако, на практике приходится сталкиваться с тем, что исследуемый файл прямо-таки кишит копиями этих виртуальных таблиц. Что же это за напасть такая, откуда она берется и как с ней бороться?

Если программа состоит из нескольких файлов, компилируемых в самостоятельные obj-модули (а такой подход используется практически во всех мало-мальски серьезных проектах), компилятор, очевидно, должен поместить в каждый obj "свою" собственную виртуальную таблицу для каждого используемого модулем класса. В самом деле – откуда компилятору знать о существовании других obj и наличии в них виртуальных таблиц? Вот так и возникают никому не нужные дубли, отъедающие память и затрудняющие анализ. Правда, на этапе компоновки, линкер может обнаружить копии и удалить их, да и сами компиляторы используют различные эвристические приемы для повышения эффективности генерируемого кода. Наибольшую популярность завоевал следующий алгоритм: виртуальная таблица помещается в тот модуль, в котором содержится реализация первой невстроенной не виртуальной функции класса. Обычно каждый класс реализуется в одном модуле и в большинстве случаев такая эвристика срабатывает. Хуже если класс состоит из одних виртуальных или встраиваемых функций – в этом случае компилятор "ложится" и начинает запихивать виртуальные таблицы во все модули, где этот класс используется. Последняя надежда на удаление "мусорных" копий ложиться на линкер, но и линкер – не панацея. Собственно, эти проблемы должны больше заботить разработчиков программы (если их волнует количество занимаемой программой памятью), для анализа лишние копии – всего лишь досадна помеха, но отнюдь не непреодолимое препятствие!

::связанный список. В большинстве случаев виртуальная таблица представляет собой обыкновенный массив, но некоторые компиляторы представляют ее в виде связного списка, - каждый элемент виртуальной таблицы содержит указатель на следующий элемент, а сами элементы размещены не вплотную друг к другу, а рассеянны по всему исполняемому файлу.



На практике подобное, однако, встречается крайне редко, поэтому, не будем подробно на этом останавливаться, - достаточно лишь знать, что такое бывает, - если встретись со списками (впрочем, навряд ли вы с ними встретитесь) – разберетесь по обстоятельствам, благо это несложно.

::вызов через шлюз. Будьте так же готовы и к тому, чтобы встретить в виртуальной таблице указатель не на виртуальную функцию, а на код, который модифицирует этот указатель, занося в него смещение вызываемой функции. Этот прием был впервые предложен самим разработчиком языка – Бьерном Страуструпом, позаимствовавшим его из ранних реализаций Алгола-60. В Алголе код, корректирующий указатель вызываемой функции, называется шлюзом (thunk), а сам вызов – вызовом через шлюз. Вполне справедливо употреблять эту терминологии и по отношению к Си++.

Однако в настоящее время вызов через шлюз чрезвычайно мало распространен и не используется практически ни одним компилятором. Несмотря на то, что он обеспечивает более компактное хранение виртуальных таблиц, модификация указателя приводит к излишним накладным расходам на процессорах с конвейерной архитектурой, (а Pentium – наиболее распространенный процессор, - как раз и построен по такой архитектуре). Поэтому, использование шлюзовых вызовов оправдано лишь в программах, критических к размеру, но не к скорости.

Подробнее обо всем этом можно прочесть в руководстве по Алголу-60 (шутка), или у Бьерна Страуструпа в "Дизайне и эволюции языка С++".

::сложный пример или когда не виртуальные функции попадают в виртуальные таблицы. До сих пор мы рассматривали лишь простейшие примеры использования виртуальных функций. В жизни же порой встречается такое… Рассмотрим сложный случай наследования с конфликтом имен:

#include <stdio.h>

class A{

public:

virtual void f() { printf("A_F\n");};

};

class B{

public:

virtual void f() { printf("B_F\n");};

virtual void g() { printf("B_G\n");};



};

class C:public A, public B {

public:

void f(){ printf("C_F\n");}      

}

main()

{

A *a = new A;

B *b = new B;

C *c = new C;

a->f();

b->f();

b->g();

c->f();

}

Листинг 30 Демонстрация помещения не виртуальных функций в виртуальные таблицы

Как будет выглядеть виртуальная таблица класса C? Так, давайте подумаем: раз класс C – производный от классов A и B, то он наследует функции обоих, но виртуальная функция f() класса B перекрывает одноименную виртуальную функцию класса A, поэтому, из класса А она не наследуется. Далее, поскольку не виртуальная функция f() присутствует и в производном классе С, она перекрывает виртуальную функцию производного класса (да, именно так, а вот не виртуальная не виртуальную функцию не перекрывает и она всегда вызывается из базового, а не производного класса). Таким образом, виртуальная таблица класса С должна содержать только один элемент – указатель на виртуальную функцию g(), унаследованную от B, а не виртуальная функция f() вызывается как обычная Си-функция. Правильно? Нет!

Это как раз тот случай, когда не виртуальная функция вызывается через указатель – как виртуальная функция. Более того, виртуальная таблица класса будет содержать не два, а три элемента! Третий элемент – это ссылка на виртуальную функцию f(), унаследованную от B, но тут же замещенная компилятором на "переходник" к C::f(). Уф… Как все непросто! Может, после изучения дизассемблерного листинга это станет понятнее?

main         proc near           ; CODE XREF: start+AFp

push   ebx

push   esi

push   edi

push   4

call   ??2@YAPAXI@Z ; operator new(uint)

add    esp, 4

; выделяем память для экземпляра объекта A

test   eax, eax

jz     short loc_0_40101C

mov    ecx, eax     ; ECX =      this

call   Get_A_VTBL   ; a[0]=*A_VTBL

; помещаем в экземпляр объекта указатель на его виртуальную таблицу

mov    ebx, eax     ; EBX =      *a

jmp    short loc_0_40101E

loc_0_40101C:                     ; CODE XREF: main+Fj



xor    ebx, ebx

loc_0_40101E:                     ; CODE XREF: main+1Aj

push   4

call   ??2@YAPAXI@Z ; operator new(uint)

add    esp, 4

; выделяем память для экземпляра объекта B

test   eax, eax

jz     short loc_0_401037

mov    ecx, eax            ; ECX = this

call   Get_B_VTBL          ; b[0] = *B_VTBL

; помещаем в экземпляр объекта указатель на его виртуальную таблицу

mov    esi, eax            ; ESI =      *b

jmp    short loc_0_401039

loc_0_401037:                     ; CODE XREF: main+2Aj

xor    esi, esi

loc_0_401039:                     ; CODE XREF: main+35j

push   8

call   ??2@YAPAXI@Z ; operator new(uint)

add    esp, 4

; выделяем память для экземпляра объекта B

test   eax, eax

jz     short loc_0_401052

mov    ecx, eax            ; ECX = this

call   GET_C_VTBLs         ; ret: EAX=*c

; помещаем в экземпляр объекта указатель на его виртуальную таблицу

; (внимание: загляните в функцию GET_C_VTBLs)

mov    edi, eax            ; EDI =      *c

jmp    short loc_0_401054

loc_0_401052:                     ; CODE XREF: main+45j

xor    edi, edi

loc_0_401054:                     ; CODE XREF: main+50j

mov    eax, [ebx]          ; EAX =      a[0] = *A_VTBL

mov    ecx, ebx            ; ECX =      *a

call   dword ptr [eax]     ; CALL [A_VTBL] (A_F)

mov    edx, [esi]          ; EDX =      b[0]

mov    ecx, esi            ; ECX =      *b

call   dword ptr [edx]     ; CALL [B_VTBL] (B_F)

mov    eax, [esi]          ; EAX =      b[0] = B_VTBL

mov    ecx, esi            ; ECX =      *b

call   dword ptr [eax+4]   ; CALL [B_VTBL+4] (B_G)

mov    edx, [edi]          ; EDX =      c[0] = C_VTBL

mov    ecx, edi            ; ECX =      *c

call   dword ptr [edx]     ; CALL [C_VTBL] (C_F)

; Внимание! Вызов не виртуальной функции происходит как виртуальной!

pop    edi

pop    esi

pop    ebx

retn  

main         endp

GET_C_VTBLs  proc near           ; CODE XREF: main+49p



push   esi          ; ESI =      *b

push   edi          ; ECX =      *c

mov    esi, ecx     ; ESI =      *c

call   Get_A_VTBL   ; c[0]=*A_VTBL

; помещаем в экземпляр объекта C указатель на виртуальную таблицу класса A

lea    edi, [esi+4] ; EDI =      *c[4]

mov    ecx, edi     ; ECX =      **_C_F

call   Get_B_VTBL   ; c[4]=*B_VTBL

; добавляем в экземпляр объекта C

указатель на виртуальную таблицу класса B

; т.е. теперь объект C содержит два указателя на две виртуальные таблицы

; базовых классов. Посмотрим далее, как компилятор справится с конфликтом

; имен…

mov    dword ptr [edi], offset C_VTBL_FORM_B ; c[4]=*_C_VTBL

; Ага! указатель на виртуальную таблицу класса B

замещается указателем

; на виртуальную таблицу класса C

(смотри комментарии в самой таблице)

mov    dword ptr [esi], offset    C_VTBL ; c[0]=C_VTBL

; Ага, еще раз – теперь указатель на виртуальную таблицу класса A замещается

; указателем на виртуальную таблицу класса C. Какой неоптимальный код, ведь это

; было можно сократить еще на стадии компиляции!

mov    eax, esi     ; EAX =      *c

pop    edi

pop    esi

retn  

GET_C_VTBLs  endp

Get_A_VTBL   proc near           ; CODE XREF: main+13p GET_C_VTBLs+4p

mov    eax, ecx

mov    dword ptr [eax], offset    A_VTBL

; помещаем в экземпляр объекта указатель на виртуальную таблицу класса B

retn  

Get_A_VTBL   endp

A_F          proc near           ; DATA XREF: .rdata:004050A8o

; виртуальная функиця f() класса A

push   offset aA_f  ; "A_F\n"

call   printf

pop    ecx

retn  

A_F          endp

Get_B_VTBL   proc near           ; CODE XREF: main+2Ep GET_C_VTBLs+Ep

mov    eax, ecx

mov    dword ptr [eax], offset    B_VTBL

; помещаем в экземпляр объекта указатель на виртуальную таблицу класса B

retn  

Get_B_VTBL   endp

B_F          proc near           ; DATA XREF: .rdata:004050ACo

; виртуальная функция f() класса B

push   offset aB_f  ; "B_F\n"



call   printf

pop    ecx

retn  

B_F          endp

B_G          proc near           ; DATA XREF: .rdata:004050B0o

; виртуальная функция g() класса B

push   offset aB_g  ; "B_G\n"

call   printf

pop    ecx

retn  

B_G          endp

C_F          proc near           ; CODE XREF: _C_F+3j

; Не виртуальная функция f() класса C

выглядит и вызывается как виртуальная!

push   offset aC_f  ; "C_F\n"

call   printf

pop    ecx

retn  

C_F          endp

_C_F         proc near           ; DATA XREF: .rdata:004050B8o

sub    ecx, 4

jmp    C_F

; смотрите, какая странная функция! Во-первых, она никогда не вызывается, а

; во-вторых, это переходник к функции C_F.

; зачем уменьшается ECX? В ECX компилятор поместил указатель this, который

; до уменьшения пытался указывать на виртуальную функцию f(), унаследованную

; от класса B. Но на самом же деле this указывал на этот переходник.

; А после уменьшения он стал указывать на предыдущий элемент виртуальной

; таблицы – т.е. функцию f() класса C, вызов которой и осуществляет JMP

_C_F         endp

A_VTBL       dd offset A_F       ; DATA XREF: Get_A_VTBL+2o

; виртуальная таблица класса A

B_VTBL       dd offset B_F       ; DATA XREF: Get_B_VTBL+2o

             dd offset B_G

; виртуальная таблица класса B – содержит указатели на две виртуальные функции

C_VTBL       dd offset C_F       ; DATA XREF: GET_C_VTBLs+19o

; виртуальная таблица класса C. Содержит указатель на не виртуальную функцию f()

C_VTBL_FORM_B dd offset _C_F             ; DATA XREF: GET_C_VTBLs+13o

             dd offset B_G

; виртуальная таблица класса C скопированная компилятором из класса B. Первоначально

; состояла из двух указателей на функции f() и g(), но еще на стадии

; компиляции компилятор разобрался в конфликте имен и заменил указатель на B::f()

; указателем на переходник к C::f()

Листинг 31

Таким образом, на самом деле виртуальная таблица производного класса включает в себя виртуальные таблицы всех базовых классов (во всяком случае, всех, откуда она наследует виртуальные функции).


В данном случае виртуальная таблица класса С содержит указатель на не виртуальную функцию С и виртуальную таблицу класса B. Задача – как определить, что функция C::f() не виртуальная? И как найти все базовые классы класса C?

Начнем с последнего – да, виртуальная таблица класса С не содержит никакого намека на его родственные отношения с классом A, но взгляните на содержимое функции GET_C_VTBLs, - видите: предпринимается попытка внедрить в C указатель на виртуальную таблицу А, следовательно, класс C – производный от A. Мне могут возразить, дескать, это не слишком надежный путь, компилятор мог бы оптимизировать код, выкинув обращение к виртуальной таблице класса А, которое все равно не нужно. Это верно, - мог бы, но на практике большинство компиляторов так не делают, а если и делают, все равно оставляют достаточно избыточной информации, позволяющей установить базовые классы. Другой вопрос – так ли необходимо устанавливать "родителей", от которых не наследуется ни одной функции? (Если хоть одна функция наследуется, никаких сложностей в поиске не возникает). В общем-то, для анализа это действительно некритично, но, чем точнее будет восстановлен исходный код программы, – тем нагляднее он будет и тем легче в нем разобраться.

Теперь перейдем к не виртуальной функции f(). Подумаем, что было бы – будь она на самом деле виртуальной? Тогда – она бы перекрыла одноименную функцию базовых классов и никакой "дикости" наподобие "переходников" в откомпилированной программе и не встретилось бы. А так – они говорят, что тут не все гладко и функция не виртуальная, хоть и стремится казаться такой. Опять-таки, умный компилятор теоретически может выкинуть переходник и дублирующийся элемент виртуальной таблицы класса С, но на практике этой интеллектуальности не наблюдается…

::статическое связывание. Есть ли разница как создавать экземпляр объекта – MyClass zzz;

или MyClass *zzz=new MyClass? Разумеется: в первом случае компилятор может определить адреса виртуальных функций еще на стадии компиляции, тогда как во втором – это приходится вычислять в ходе выполнения программы.


Другое различие: статические объекты размешаются в стеке (сегменте данных), а динамические – в куче. Таблица виртуальных функций упорно создается компиляторами в обоих случаях, а при вызове каждый функции (включая не виртуальные) подготавливается указатель this (как правило, помещаемый в один из регистров общего назначения – подробнее см. "Идентификация аргументов функций"), содержащий адрес экземпляра объекта.

Таким образом, если мы встречаем функцию, вызываемую непосредственно по ее смещению, но в то же время присутствующую в виртуальной таблице класса – можно с уверенностью утверждать, что это – виртуальная функция статичного экземпляра объекта.

Рассмотрим следующий пример:

#include <stdio.h>

class Base{

 public:

virtual void demo(void)

{

printf("BASE DEMO\n");

};

virtual void demo_2(void)

{

printf("BASE DEMO 2\n");

};

void demo_3(void)

{

printf("Non virtual BASE DEMO 3\n");

};

};

class Derived: public Base{

 public:

virtual void demo(void)

{

printf("DERIVED DEMO\n");

};

virtual void demo_2(void)

{

printf("DERIVED DEMO 2\n");

};

void demo_3(void)

{

printf("Non virtual DERIVED DEMO 3\n");

};

};

main()

{

Base p;

p.demo();

p.demo_2();

p.demo_3();

Derived d;

d.demo();

d.demo_2();

d.demo_3();

}

Листинг 32 Демонстрация вызова статической виртуальной функции

Результат ее компиляции в общем случае должен выглядеть так:

main         proc near           ; CODE XREF: start+AFp

var_8        = byte ptr -8       ; derived

var_4        = byte ptr -4       ; base

; часто, (но не всегда!) экземпляры объектов в стеке расположены снизу вверх,

; т.е. в обратном порядке их объявления в программе

push   ebp

mov    ebp, esp

sub    esp, 8

lea    ecx, [ebp+var_4] ; base

call   GetBASE_VTBL               ; p[0]=*BASE_VTBL

; обратите внимание – экземпляр объекта размещается в стеке,



; а не в куче! Это, конечно, не еще не свидетельствует о статичной

; природе экземпляра объекта (динамичные объекты тоже могут размещаться в стеке)

; но намеком на "статику" все же служит

lea    ecx, [ebp+var_4] ; base

; подготавливаем указатель this (на тот случай если он понадобится функции)

call   BASE_DEMO

; непосредственный вызов функции! Вот, вкупе с ее наличием в виртуальной таблице

; свидетельство статичности объявления экземпляра объекта!

lea    ecx, [ebp+var_4] ; base

; вновь подготавливаем указатель this

на экземляр base

call   BASE_DEMO_2

; непосредственный вызов функции. Она есть в виртуальной таблице? Есть!

; значит, это виртуальная функция, а экземпляр объекта объявлен статичным

lea    ecx, [ebp+var_4] ; base

; готовим указатель this для не виртуальной

функции demo_3

call   BASE_DEMO_3

; этой функции нет в виртуальной таблице (см. виртуальную таблицу)

; значит, она не виртуальная

lea    ecx, [ebp+var_8] ; derived

call   GetDERIVED_VTBL     ; d[0]=*DERIVED_VTBL

lea    ecx, [ebp+var_8] ; derived

call   DERIVED_DEMO

; аналогично предыдущему...

lea    ecx, [ebp+var_8] ; derived

call   DERIVED_DEMO_2

; аналогично

предыдущему...

lea    ecx, [ebp+var_8] ; derived

call   BASE_DEMO_3_

; внимание! Указатель this указывает на объект DERIVED, в то время как

; вызывается функция объекта BASE!!! Значит, функция BASE – производная

mov    esp, ebp

pop    ebp

retn  

main         endp

BASE_DEMO    proc near           ; CODE XREF: main+11p

; функция demo класса BASE

push   offset aBase ; "BASE\n"

call   printf

pop    ecx

retn  

BASE_DEMO    endp

BASE_DEMO_2  proc near           ; CODE XREF: main+19p

; функция demo_2 класса BASE

push   offset aBaseDemo2 ; "BASE DEMO 2\n"

call   printf

pop    ecx

retn  

BASE_DEMO_2  endp

BASE_DEMO_3  proc near           ; CODE XREF: main+21p



; функция demo_3 класса BASE

push   offset aNonVirtualBase ; "Non virtual BASE DEMO       3\n"

call   printf

pop    ecx

retn  

BASE_DEMO_3  endp

DERIVED_DEMO proc near           ; CODE XREF: main+31p

; функция demo класса DERIVED

push   offset aDerived     ; "DERIVED\n"

call   printf

pop    ecx

retn  

DERIVED_DEMO endp

DERIVED_DEMO_2      proc near           ; CODE XREF: main+39p

; функция demo класса DERIVED

push   offset aDerivedDemo2 ; "DERIVED   DEMO 2\n"

call   printf

pop    ecx

retn  

DERIVED_DEMO_2      endp

BASE_DEMO_3_ proc near           ; CODE XREF: main+41p

; функция demo_3 класса BASE

; Внимание! Смотрите – функция demo_3 дважды присутствует в программе!

; первый раз она входила в объект класса BASE, а второй – в объект класса

; DERIVED, который унаследовал ее от базового класса и сделал копию

; глупо, да? ведь лучше бы он обратился к оригиналу... Зато это упрощает

; анализ программы...

push   offset aNonVirtualDeri ; "Non virtual DERIVED DEMO 3\n"

call   printf

pop    ecx

retn  

BASE_DEMO_3_ endp

GetBASE_VTBL proc near           ; CODE XREF: main+9p

; занесение в экземпляр объекта BASE

смещения его виртуальной таблицы

mov    eax, ecx

mov    dword ptr [eax], offset    BASE_VTBL

retn  

GetBASE_VTBL endp

GetDERIVED_VTBL     proc near           ; CODE XREF: main+29p

; занесение в экземпляр объекта DERIVED

смещения его виртуальной таблицы

push   esi

mov    esi, ecx

call   GetBASE_VTBL

; ага! Значит, наш объект – производный от BASE!

mov    dword ptr [esi], offset    DERIVED_VTBL

; занесение указателя на виртуальную таблицу DERIVED

mov    eax, esi

pop    esi

retn  

GetDERIVED_VTBL     endp

BASE_VTBL    dd offset BASE_DEMO ; DATA XREF: GetBASE_VTBL+2o

             dd offset BASE_DEMO_2

DERIVED_VTBL dd offset DERIVED_DEMO     ; DATA XREF: GetDERIVED_VTBL+8o



             dd offset DERIVED_DEMO_2

; обратите внимание на наличие виртуальной таблицы даже там, где она не нужна!

Листинг 33

::идентификация производных функций. Идентификация производных не виртуальных функций – весьма тонкий момент. На первый взгляд, коль они вызываются как и обычные Си-функции, распознать: в каком классе была объявлена функция невозможно – компилятор уничтожает эту информацию еще на стадии компиляции. Уничтожает, да не всю! Перед каждым вызовом функции (не важно производной или нет) в обязательном порядке формируется указатель this – на тот случай если он понадобится функции, указывающей на объект из которого вызывается эта функция. Для производных функций указатель this хранит смещение производного, а не базового объекта. Вот оно! Если функция вызывается с различными указателями this – это производная функция.

Сложнее выяснить – от какого объекта она происходит. Универсальных решений нет, но если выделить объект A с функциями f1(), f2()… И объект B с функциями f1(), f3(),f4()… то можно смело утверждать, что f1() – функция, производная от класса А. Правда, если из экземпляра класса функция f1() не вызывалась ни разу – определить производная она или нет – не удастся.

Рассмотрим все это на следующем примере:

#include <stdio.h>

class Base{

 public:

void base_demo(void)

{

printf("BASE DEMO\n");

};

void base_demo_2(void)

{

printf("BASE DEMO 2\n");

};

};

class Derived: public Base{

 public:

void derived_demo(void)

{

printf("DERIVED DEMO\n");

};

void derived_demo_2(void)

{

printf("DERIVED DEMO 2\n");

};

};

Листинг 34 Демонстрация идентификации производных функций

Результат компиляции в общем случае должен выглядеть так:

main   proc near           ; CODE XREF: start+AFp

push   esi

push   1

call   ??2@YAPAXI@Z ; operator new(uint)

; создаем новый экземпляр некоторого объекта. Пока мы еще не знаем какого

; пусть это будет объект A



mov    esi, eax            ; ESI = *a

add    esp, 4

mov    ecx, esi            ; ECX = *a (this)

call   BASE_DEMO

; вызываем BASE_DEMO, обращая внимание на то, что this

указывает на 'a'

mov    ecx, esi            ; ECX = *a (this)

call   BASE_DEMO_2

; вызываем BASE_DEMO_2, обращая внимание на то, что this

указывает на 'a'

push   1

call   ??2@YAPAXI@Z ; operator new(uint)

; создаем еще один экземпляр некоторого объекта, назовем его b

mov    esi, eax            ; ESI = *b

add    esp, 4

mov    ecx, esi            ; ECX = *b (this)

call   BASE_DEMO

; Ага! Вызываем BASE_DEMO, но на этот раз this

указывает на b

; значит, BASE_DEMO

связана родственными отношениями и с 'a' и с 'b'

mov    ecx, esi

call   BASE_DEMO_2

; Ага! Вызываем BASE_DEMO_2, но на этот раз this

указывает на b

; значит, BASE_DEMO_2 связана родственными отношениями и с 'a' и с 'b'

mov    ecx, esi

call   DERIVED_DEMO

; вызываем DERIVED_DEMO. Указатель this указывает на b, и никаких родственных

; связей DERIVED_DEMO

с 'a' не замечено. this

никогда не указывал на 'a'

; при ее вызове

mov    ecx, esi

call   DERIVED_DEMO_2

; аналогично...

pop    esi

retn  

main   endp

Листинг 35

Ок, идентификация не виртуальных производных функций – вполне реальное дело. Единственная сложность – отличить экземпляры двух различных объектов от экземпляров одного и того же объекта.

Что же касается идентификации производных виртуальных функций – об этом уже рассказывалось выше. Производные виртуальные функции вызываются в два этапа – на первом в экземпляр объекта заносится смещение виртуальной таблицы базового класса, а затем оно замещается смещением виртуальной таблицы производного класса. Даже если компилятор оптимизирует код, оставшейся избыточности все равно с лихвой хватит для отличия производных функций от остальных.

::идентификация виртуальных таблиц. Теперь, основательно освоившись с виртуальными таблицами и функциями, рассмотрим очень коварный вопрос – всякий ли массив указателей на функции есть виртуальная таблица? Разумеется, нет! Ведь косвенный вызов функции через указатель – частое дело в практике программиста.


Массив указателей на функции… хм, конечно типичным его не назовешь, но и такое в жизни встречается!

Рассмотрим следующий пример – кривой и наигранный конечно, но чтобы продемонстрировать ситуацию, где массив указателей жизненно необходим, пришлось бы написать не одну сотню строк кода:

#include <stdio.h>

void demo_1(void)

{

printf("Demo 1\n");

}

void demo_2(void)

{

printf("Demo 2\n");

}

void call_demo(void **x)

{

((void (*)(void)) x[0])();

((void (*)(void)) x[1])();

}

main()

{

static void* x[2] =

{ (void*) demo_1,(void*) demo_2};

// Внимание: если инициализировать массив не при его объявлении

// а по ходу программы, т.е. x[0]=(void *) demo_1,...

// то компилятор сгенерирует адекватный код, заносящий

// смещения функций в ходе выполнения программы, что будет

// совсем не похоже на виртуальную таблицу!

// Напротив, инициализация при объявлении помещает уже

// готовые указатели в сегмент данных, смахивая на настоящую

// виртуальную таблицу (и экономя такты процессора к тому же)

call_demo(&x[0]);

}

Листинг 36 Демонстрация имитации виртуальных таблиц

А теперь посмотрим – сможем ли мы отличить "рукотворную" таблицу указателей от настоящей:

main   proc near           ; CODE XREF: start+AFp

push   offset Like_VTBL

call   demo_call

; ага, функции передается указатель на нечто очень похожее на виртуальную

; таблицу. Но мы-то, уже умудренные опытом, с легкостью раскалываем эту

; грубую подделку. Во-первых, указатели на VTBL

так просто не передаются,

; (там не такой тривиальный код), во-вторых они передаются не через стек,

; а через регистр. В-третьих, указатель на виртуальную таблицу ни одним

; существующим компилятором не используется непосредственно, а помещается

; в объект. Тут же нет ни объекта, ни указателя this

– в четвертых.

; словом, это не виртуальная таблица, хотя на беглый, нетренированный

; взгляд очень на нее похожа...



pop    ecx

retn  

main   endp

demo_call    proc near           ; CODE XREF: sub_0_401030+5p

arg_0        = dword      ptr  8

; вот-с! указатель – аргумент, а к виртуальным таблицам идет обращение

; через регистр...

push   ebp

mov    ebp, esp

push   esi

mov    esi, [ebp+arg_0]

call   dword ptr [esi]

; происходит двухуровневый вызов функции – по указателю на массив

; указателей на функцию, что характерно для вызова виртуальных функций

; но, опять-таки слишком тривиальный код, - вызов виртуальных функций

; сопряжен с большой избыточностью, а во-вторых опять нет указателя this

call   dword ptr [esi+4]

; аналогично – слишком просто для вызова виртуальной функции

pop    esi

pop    ebp

retn  

demo_call    endp

Like_VTBL    dd offset demo_1    ; DATA XREF:main

dd offset demo_2

; массив указателей внешне похож на виртуальную таблицу, но

; расположен "не там" где обычно располагаются виртуальные таблицы

Листинг 37

Обобщая выводы, разбросанные по комментариям, повторим основные признаки "подделки" еще раз:

- слишком тривиальный код, - минимум используемых регистров и никакой избыточности, обращение к виртуальным таблицам происходит куда витиеватее;

- указатель на виртуальную функцию заносится в экземпляр объекта, и передается он не через стек, а через регистр (точнее – см. "Идентификация this");

- отсутствует указатель this, всегда подготавливаемый перед вызовом виртуальной функции;

- виртуальные функции и статические переменные располагаются в различных местах сегмента данных – поэтому сразу можно отличить одни от других.

А можно ли так организовать вызов функции по ссылке, чтобы компиляция программы давала код идентичный вызову виртуальной функции? Как сказать… Теоретически да, но практически – едва ли такое удастся осуществить (а уж непреднамеренно – тем более). Код вызова виртуальных функций в связи с большой избыточностью очень специфичен и легко различим "на глаз".Легко сымитировать общую технику работы с виртуальными таблицами, но без ассемблерных вставок невозможно воспроизвести ее в точности.

::заключение.

Вообще же, как мы видим, работа с виртуальными функциями сопряжена с огромной избыточностью и "тормозами", а их анализ связан с большими трудозатратами – приходится постоянно держать в голове множество указателей и помнить какой из них на что указывает. Но, как бы там ни было, никаких принципиально-неразрешимых преград перед исследователем не стоит.


Содержание раздела