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

Идентификация конструктора и деструктора


"то, что не существует в одном тексте (одном возможном мире), может существовать в других текстах (возможных мирах)"

тезис семантики возможных миров

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

Заглянув в описание языка Си++, можно обнаружить, что конструктор не возвращает никакого значения, что нехарактерно для обычных функций, однако, все же не настолько редко встречается, чтобы однозначно идентифицировать конструктор. Как же тогда быть?

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

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

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

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

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

#include <stdio.h>

class MyClass{



 public:

MyClass(void);

void demo(void);

~MyClass(void);

};

MyClass::MyClass()

{

printf("Constructor\n");

}

MyClass::~MyClass()

{

printf("Destructor\n");

}

void MyClass::demo(void)

{

printf("MyClass\n");

}

main()

{

MyClass *zzz = new MyClass;

zzz->demo();

delete zzz;

}

Листинг 38 Демонстрация конструктора и деструктора

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

Constructor  proc near           ; CODE XREF: main+11p

; функция конструктора. То, что это именно конструктор можно понять из реализации

; его вызова (см. main)

push   esi

mov    esi, ecx

push   offset aConstructor ; "Constructor\n"

call   printf

add    esp, 4

mov    eax, esi

pop    esi

retn  

Constructor  endp

Destructor   proc near           ; CODE XREF: __destructor+6p

; функция деструктора. То, что это именно деструктор, можно понять из реализации



; его вызова (см. main)

push   offset aDestructor ; "Destructor\n"

call   printf

pop    ecx

retn  

Destructor   endp

demo         proc near           ; CODE XREF: main+1Ep

; обычная

функия demo

push   offset aMyclass     ; "MyClass\n"

call   printf

pop    ecx

retn  

demo         endp

main         proc near           ; CODE XREF: start+AFp

push   esi

push   1

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

add    esp, 4

; выделяем память для нового объекта

; точнее, пытаемся это сделать

test   eax, eax

jz     short loc_0_40105A

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

; Обратите внимание: куда направлен jump.

; Он направлен на инструкцию XOR ESI,ESI, обнуляющую указатель на объект –

; при попытке использования нулевого указателя возникнет исключение,

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

; отвести не удалось.

; Поэтому, конструктор получает управление только при успешном отводе памяти!

; Следовательно, функция, находящаяся до XOR ESI,ESI, и есть конструктор!!!

; И мы сумели надежно идентифицировать ее.

mov    ecx, eax

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

call   Constructor

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

mov    esi, eax

jmp    short loc_0_40105C

loc_0_40105A:                     ; CODE XREF: main+Dj

xor    esi, esi

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

; использования

; Внимание: конструктор никогда не вызывает исключения, поэтому,

; нижележащая функция гарантированно не является конструктором

loc_0_40105C:                     ; CODE XREF: main+18j

mov    ecx, esi

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

call   demo

; вызываем обычную функцию объекта

test   esi, esi

jz     short loc_0_401070

; проверка указателя this на NULL. Деструктор вызываться только в том случае

; если память под объект была отведена (если же она не была отведена



; освобождать особо нечего)

; таким образом, следующая функция – именно деструктор, а не что-нибудь еще

push   1

; количество байт для освобождения (необходимо для delete)

mov    ecx, esi

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

call   __destructor

; вызываем деструктор

loc_0_401070:                     ; CODE XREF: main+25j

pop    esi

retn  

main         endp

__destructor proc near           ; CODE XREF: main+2Bp

; функция деструктора. Обратите внимание, что деструктор обычно вызывается

; из той же функции, что и delete (хотя так бывает и не всегда, но очень часто)

arg_0        = byte ptr  8

push   ebp

mov    ebp, esp

push   esi

mov    esi, ecx

call   Destructor

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

test   [ebp+arg_0], 1

jz     short loc_0_40109A

push   esi

call   ??3@YAXPAX@Z ; operator delete(void *)

add    esp, 4

; освобождаем память, ранее выделенную объекту

loc_0_40109A:                     ; CODE XREF: __destructor+Fj

mov    eax, esi

pop    esi

pop    ebp

retn   4

__destructor endp

Листинг 39

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

Чтобы убедиться в этом, модифицируем функцию main нашего предыдущего примера следующим образом:

main()

{

MyClass zzz;

zzz.demo();

}

Листинг 40

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

main         proc near           ; CODE XREF: start+AFp

var_4        = byte ptr -4

; локальная переменная zzz – экземпляр объекта MyClass



push   ebp

mov    ebp, esp

push   ecx

lea    ecx, [ebp+var_4]

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

call   constructor

; вызываем конструктор, как и обычную функцию!

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

; (обычно конструктор инициализирует объект), да и то неуверенно

lea    ecx, [ebp+var_4]

call   demo

; вызываем функцию demo, - обратите внимание, ее вызов ничем не отличается

; от вызова конструктора!

lea    ecx, [ebp+var_4]

call   destructor

; вызываем деструктор – его вызов, как мы уже поняли, ничем

; характерным не отмечен

mov    esp, ebp

pop    ebp

retn  

main         endp

Листинг 41

::идентификация конструктора/деструктора в глобальных объектах. Глобальные объекты (так же называемые статическими объектами) размешаются в сегменте данных еще на стадии компиляции. Стало быть, ошибки выделения памяти в принципе невозможны и, выходит, что по аналогии со стековыми объектами, надежно идентифицировать конструктор/деструктор и здесь нельзя? А вот и нет!

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

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


Интересный момент - _atexit (или что там используется в вашем конкретном случае) должна быть вызвана лишь однократно (надеюсь, понятно почему?). И, чтобы не вводить еще один флаг, она вызывается сразу же после вызова конструктора! На первый взгляд объект может показаться состоящим из одних конструктора/деструктора, но это не так! Не забывайте, что _atexit не передает немедленно управление на код деструктора, а только запоминает его указатель для дальнейшего использования!

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

main()

{

static MyClass zzz;

zzz.demo();

}

Листинг 42

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

main         proc near           ; CODE XREF: start+AFp

mov    cl, byte_0_4078E0 ; флаг инициализации экземпляра     объекта

mov    al, 1

test   al, cl

; объект инициализирован?

jnz    short loc_0_40106D

; --> да, инициализирован, - не вызываем конструктор

mov    dl, cl

mov    ecx, offset unk_0_4078E1 ; экземляр объекта

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

or     dl, al

; устанавливаем флаг инициализации в TRUE

; и вызываем конструктор

mov    byte_0_4078E0, dl ; флаг инициализации экземпляра     объекта

call   constructor

; Вызов конструктора.

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

; (см. проверку выше) конструктор не вызывается.

; Таким образом, его очень легко отождествить!

push   offset thunk_destructo

call   _atexit

add    esp, 4

; Передаем функции _atexit указатель на деструктор,

; который она должна вызвать по завершении программы

loc_0_40106D:                     ; CODE XREF: main+Aj

mov    ecx, offset unk_0_4078E1 ; экземпляр

объекта

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

jmp    demo

; вызываем demo

main         endp

thunk_destructo:                  ; DATA XREF: main+20o

; переходник к функции-деструктору



mov    ecx, offset unk_0_4078E1 ; экземпляр объекта

jmp    destructor

byte_0_4078E0 db 0                ; DATA XREF: mainr main+15w

                                  ; флаг инициализации экземпляра объекта

unk_0_4078E1 db    0      ;            ; DATA XREF: main+Eo main+2Do   ...

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

Листинг 43

Аналогичный код генерирует и Borland C++. Единственное отличие – более хитрый вызов деструктора. Вызовы всех деструкторов помещены в специальную процедуру, которая выдает себя тем, что обычно располагается перед библиотечными функциями (или в непосредственной близости от них), так что идентифицировать ее очень легко. Смотрите сами:

_main        proc near           ; DATA XREF: DATA:00407044o

push   ebp

mov    ebp, esp

cmp    ds:byte_0_407074, 0 ; флаг инициализации объекта

jnz    short loc_0_4010EC

; Если объект уже инициализирован – конструктор не вызывается

mov    eax, offset unk_0_4080B4 ; Экземпляр объекта

call   constructor

inc    ds:byte_0_407074 ; флаг инициализации объекта

; Увеличиваем флаг на единицу, возводя его в TRUE

loc_0_4010EC:                     ; CODE XREF: _main+Aj

mov    eax, offset unk_0_4080B4 ; Экземляр

объекта

call   demo

; Вызов

функции demo

xor    eax, eax

pop    ebp

retn  

_main        endp

call_destruct proc near           ; DATA XREF: DATA:004080A4o

; Эта функция содержит в себе вызовы всех деструкторов глобальных объектов,

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

; эту функцию легко идентифицировать – только она содержит подобный "калечный код"

; (вызовы конструкторов обычно разбросаны по всей программе)

push   ebp

mov    ebp, esp

cmp    ds:byte_0_407074, 0 ; флаг инициализации объекта

jz     short loc_0_401117

; объект был инициализирован?

mov    eax, offset unk_0_4080B4 ; Экземпляр объекта

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

mov    edx, 2

call   destructor



; вызываем деструктор

loc_0_401117:                     ; CODE XREF: call_destruct+Aj

pop    ebp

retn  

call_destruct endp

Листинг 44

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

::виртуальный конструктор. Виртуальный конструктор?! А что, разве есть такой? Ничего подобного стандартный Си++ не поддерживает. Непосредственно не поддерживает. И, когда виртуальный конструктор позарез требуется программистом (впрочем, бывает это лишь в весьма экзотических случаях), они прибегают к ручной эмуляции некоторого его подобия. В специально выделенную для этих целей виртуальную функцию (не конструктор!) помещается приблизительно следующий код: "return new имя класса (*this)" или "return new имя класса (*this)". Этот трюк кривее, чем бумеранг, но… он работает. Разумеется, существуют и другие решения.

Подробное их обсуждение далеко выходит за рамки данной книги и требует глубоко знания Си++ (гораздо более глубокого, чем у рядового разработчика), к тому же это заняло бы слишком много места… но едва ли оказалось интересно рядовому читателю.

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


Не слишком- то надежно для идентификации, но все же лучше, чем ничего.

::конструктор раз, конструктор два… Количество конструкторов объекта может быть и более одного (и очень часто не только может, но и бывает). Однако это никак не влияет на анализ. Сколько бы конструкторов ни присутствовало, – для каждого экземпляра объекта всегда вызывается только один, выбранный компилятором в зависимости от формы объявления объекта. Единственная деталь – различные экземпляры объекта могут вызывать различные конструкторы – будьте внимательны!

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


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