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

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

Если первого встречного программиста спросить, с какой функции начинается выполнение Windows-программы, вероятнее всего, мы услышим в ответ — «с WinMain». И это будет ошибкой. На самом деле первым управление получает стартовый код, скрыто вставляемый компилятором. Выполнив необходимые инициализационные процедуры, в какой-то момент он вызывает WinMain, а после ее завершения вновь получает управление и выполняет капитальную деинициализацию.

Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Я попытаюсь обновить этот объемный труд и перенести его из времен Windows 2000 и Visual Studio 6.0 во времена Windows 10 и Visual Studio 2017.

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

В подавляющем большинстве случаев стартовый код не представляет никакого интереса, и первой задачей при анализе становится поиск функции WinMain. Если компилятор входит в число «знакомых» IDA, она опознает WinMain автоматически, в противном же случае искать функцию приходится руками и головой.

РЕКОМЕНДУЕМ:
Исследование алгоритма работы программ

Обычно в штатную поставку компилятора включают исходные тексты его библиотек, в том числе и процедуры стартового кода. Например, у Microsoft Visual C++ стартовый код расположен в файле \VC\crt\src\vcruntime\mcrtexe.cpp. В нем содержится код для инициализации ASCII-версий консольных (main) приложений. В этой же папке лежат еще несколько файлов:

  • mwcrtexe.cpp — код из этого файла используется при старте консольных приложений с Unicode-символами;
  • mcrtexew.cpp — вызывается при запуске Windows-приложений (WinMain) с поддержкой ASCII;
  • mwcrtexew.cpp — служит для запуска Windows-приложений с Юникодом.

После выполнения весьма небольшого блока кода управление из трех последних файлов передается в первый. У Embarcadero C++ Builder 10.3 (в девичестве Borland C++) все файлы со startup-кодом хранятся в отдельной одноименной директории. В частности, файлы, содержащие стартовый код для Windows-приложений, находятся в папке \source\cpprtl\Source\startup\. Ее содержимое несколько похоже на vcruntime тем, что имеется главный файл для запуска Win32-приложений — c0nt.asm. Код из этого файла вызывают другие файлы, содержащие инициализации для подсистем на Win32: DLL, VCL, FMX (приложение FireMonkey, кросс-платформенная графическая подсистема). Если разобраться с исходными текстами, понять дизассемблированный листинг будет намного легче!

Embarcadero C++Builder. Начиная с этой версии у данной системы программирования появилась Community-редакция, которую можно халявно использовать целый год

Embarcadero C++Builder. Начиная с этой версии у данной системы программирования появилась Community-редакция, которую можно халявно использовать целый год

А как быть, если для компиляции исследуемой программы использовался неизвестный или недоступный тебе компилятор? Прежде чем приступать к утомительному ручному анализу, давай вспомним, какой прототип имеет функция WinMain:

Обрати внимание: APIENTRY замещает WINAPI. Во-первых, четыре аргумента — это достаточно много, и в большинстве случаев WinMain оказывается самой «богатой» на аргументы функцией стартового кода. Во-вторых, последний заносимый в стек аргумент — hInstance — чаще всего вычисляется на лету вызовом функции GetModuleHandleW. То есть, если встретишь конструкцию типа CALL GetModuleHandleW, можно с высокой степенью уверенности утверждать, что следующая функция и есть WinMain. Наконец, вызов WinMain обычно расположен практически в самом конце кода стартовой функции. За ней бывает не более двух-трех «замыкающих» строй функций, например __cexit.

Компилили, компилим и будем компилить!

До сего момента мы компилили наши примеры из командной строки:

В результате мы получали строгий, очищенный от мишуры машинный код, после дизассемблирования которого в ассемблерном листинге отсутствовали какие бы то ни было комментарии и внятные названия функций. Теперь мы будем строить наши приложения прямиком из среды разработки (это по-прежнему Visual Studio 2017), чтобы при анализе кода использовать средства, предоставляемые самим компилятором. Мы уже достаточно поупражнялись и базовые конструкции языка высокого уровня можем определять с закрытыми глазами: на ощупь и по запаху!

Чтобы повторить следующий пример, в Visual Studio 2017 на C++ создай Windows Desktop Application, скомпилируй приложение для платформы x64, а затем итоговый экзешник открой в IDA. Рассмотрим получившийся код повнимательнее.

Первое, на что стоит обратить внимание, — это отличие от 32-разрядной платформы: здесь параметры передаются не через стек, а через регистры процессора. Раньше на платформе x86 в вызывающем коде параметры заталкивались в стек с помощью инструкции PUSH, а в вызываемой процедуре извлекались из него инструкцией POP. При заталкивании параметров в стек неизбежно приходится обращаться по адресам памяти. А мы уже крепко усвоили, что обращение по адресам памяти, отсутствующим в кеше процессора, занимает несоизмеримо больше времени.

Однако через регистры передаются только первые четыре аргумента: целочисленные через RCX, RDX, R8, R9, значения с плавающей точкой через XMM0, XMM1, XMM2, XMM3. Если же параметров больше (а это довольно редкий случай), то они так же, как и раньше, передаются через стек.
В комментариях IDA дала аргументам осмысленные имена:

Но как понять, что находится по адресу cs:140000000h? IDA в комментарии говорит, что там должен быть аргумент hInstance:

Ассемблерная команда lea позволяет получить текущий адрес источника (второй операнд). Значит, получение hInstance должно выполняться через косвенный вызов функции GetModuleHandleW.

Если опустить взгляд на пару строчек ниже вызова WinMain, мы увидим вызов __scrt_is_managed_app, войдя в который обнаружим вызов нужной функции:

И уже последняя возвращает дескриптор модуля. Но не всегда это выглядит столь же просто. Многие разработчики, пользуясь наличием исходных текстов startup-кода, модифицируют его (подчас весьма значительно). В результате выполнение программы может начинаться не с WinMain, а с любой другой функции. К тому же теперь стартовый код может содержать критические для понимания алгоритма операции (например, расшифровщик основного кода)! Поэтому всегда хотя бы мельком следует изучить startup-код: не содержит ли он чего-нибудь необычного?

Аналогичным образом обстоят дела и с динамическими библиотеками — их выполнение начинается вовсе не с функции DllMain (если она, конечно, вообще присутствует в DLL), а по умолчанию с __DllMainCRTStartup. Впрочем, разработчики подчас изменяют умолчания, назначая ключом /ENTRY ту стартовую функцию, которая им нужна.

Строго говоря, неправильно называть DllMain стартовой функцией. Она вызывается не только при загрузке DLL, но и при выгрузке, и при создании либо уничтожении нового потока подключившим ее процессом.

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

Если DllMain выполняет какие-то действия (скажем, инициализирует переменные), то остальные функции, на которые распространяется влияние этих переменных, будут содержать прямые ссылки на них, ведущие к DllMain. Таким образом, не стоит «вручную» искать DllMain — она сама себя обнаружит!

Хорошо, если бы всегда это было так! Но жизнь сложнее всяких правил. Вдруг в DllMain находится некий деструктивный код или библиотека в дополнение к основной своей деятельности шпионит за потоками, отслеживая их появление? Тогда без непосредственного анализа ее кода не обойтись.

Обнаружить DllMain на порядок труднее, чем WinMain. Если ее не найдет IDA — пиши пропало. Во-первых, прототип DllMain достаточно незамысловат и не содержит ничего характерного:

А во-вторых, ее вызов идет из самой гущи довольно внушительной функции __DllMainCRTStartup, так что найти именно тот CALL, который нам нужен, нет никакой возможности. Впрочем, некоторые зацепки все-таки есть. Например, при неудачной инициализации DllMain возвращает FALSE, и код __DllMainCRTStartup обязательно проверит это значение, в случае чего прыгая аж к концу функции. Подобных ветвлений в теле стартовой функции не так уж много, и обычно только одно из них связано с функцией, принимающей три аргумента.

Для проведения следующего эксперимента в Visual Studio 2017 создай простую динамическую библиотеку и откомпилируй ее релизную версию под платформу x64. Затем загрузи ее в IDA. Следующий листинг призван показать идентификацию DllMain по коду неудачной инициализации:

Вверху приведенного листинга видно, что регистры R8, EDX и RCX содержат IpvReserved, fdwReason и hinstDLL соответственно. И как видно, аргументы передаются не через стек, а с помощью регистров. Значит, перед нами и есть функция DllMain (исходный текст _DllMainCRTStartup содержится в файле \VC\crt\src\vcruntime\dll_dllmain.cpp, который настоятельно рекомендуется изучить).

Консольные приложения

Наконец, мы добрались и до функции main консольных приложений. Как всегда, выполнение программы начинается не с нее, а c функции mainCRTStartup, инициализирующей кучу, систему ввода-вывода, подготавливающую аргументы командной строки и только потом передающей управление main. Функция main принимает всего два аргумента: int main (int argc, char **argv) — этого слишком мало, чтобы выделить ее среди остальных. Однако на помощь приходит тот факт, что ключи командной строки доступны не только через аргументы, но и через глобальные переменные — argc и argv соответственно. Поэтому вызов main обычно выглядит так:

Чтобы повторить эксперимент, в VS 2017 создай и скомпилируй 64-битное консольное приложение, затем открой его в IDA.

Первым делом забираем два параметра из командной строки, вызвав функции __p___argv_0 и __p___argc_0. Далее их значения размещаются в регистрах процессора и таким образом передаются в функцию main. Опять же, отличие от 32-битной среды в том, что в ней они запихивались в стек.

После того как main отработает, анализируется возвращенное ей значение, на основании чего выполняются прыжки на соответствующие ветви кода. Например, main может отработать по-разному и в случае возврата ошибки приложение, как вариант, надо уничтожить. Если же все идет по плану, то вызывается _cexit_0 и все довольны.

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

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

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

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

Вызов виртуальных функций всегда происходит косвенно, через ссылку на виртуальную таблицу, например: CALL [RBX+0x10], где RBX — регистр, содержащий смещение виртуальной таблицы в памяти, а 0x10 — смещение указателя на виртуальную функцию внутри виртуальной таблицы.

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

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

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

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

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

Заносим в RCX указатель на экземпляр объекта (указатель на указатель на BASE_VTBL). Другими словами, передаем вызываемой функции неявный аргумент — указатель this (к слову, поскольку this считается целочисленным аргументом, он всегда помещается в регистр RCX).

По адресу, на который указывает RBX, помещаем указатель на виртуальную таблицу класса Base, не забывая о том, что указатель на виртуальную таблицу одновременно указатель и на первый элемент этой таблицы. А первый элемент виртуальной таблицы содержит указатель на первую (в порядке объявления) виртуальную функцию класса.

Вот он, вызов виртуальной функции! Чтобы понять, какая именно функция вызывается, мы должны знать значение регистра RAX. Прокручивая экран дизассемблера вверх, мы видим: RAX указывает на BASE_VTBL - - Base:: 'vftable', а первый член BASE_VTBL (см. ниже) указывает на функцию Base::demo. Следовательно:

а) этот код вызывает именно функцию Base::demo;
б) функция Base::demo — это виртуальная функция.

Заносим в RDX указатель на первый элемент виртуальной таблицы класса Base.

Заносим в RCX указатель на экземпляр объекта: это неявный аргумент функции — указатель this.

Еще один вызов виртуальной функции! Чтобы понять, какая именно функция вызывается, мы должны знать содержимое регистра RDX. Прокручивая экран дизассемблера вверх, видим, что он указывает на BASE_VTBL, а RDX+8, стало быть, указывает на второй элемент виртуальной таблицы класса Base. Он же, в свою очередь, указывает на функцию Base::demo_2.

А вот вызов невиртуальной функции. Обрати внимание — он происходит так же, как вызов обычной С-функции. Заметь, эта функция встроенная, так как объявлена непосредственно в самом классе и вместо ее вызова выполняется подстановка кода.

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

Искаженные имена

Если у тебя в окне дизассемблера имена функций выглядят немного неожиданно (с примесью знаков вопроса и коммерческого at — ?, @) и это доставляет дискомфорт, можешь отключить замангливание (кромсание) имен. Для этого в IDA открой окно Demangled C++ names ( Options -> Demangled names), поставь переключатель на пункт Names и жми OK. После этой нехитрой операции имена функций примут привычный вид.

Окно для отключения замангливания
Окно для отключения замангливания

Вот как выглядят наши функции:

Таблицы виртуальных методов:

Обрати внимание: виртуальные таблицы «растут» снизу вверх в порядке объявления классов в программе, а элементы виртуальных таблиц «растут» сверху вниз в порядке объявления виртуальных функций в классе. Конечно, так бывает не всегда. Порядок размещения таблиц и их элементов нигде не декларирован и целиком лежит на «совести» компилятора. Но на практике большинство из них ведет себя именно так. Сами же виртуальные функции располагаются вплотную друг к другу в порядке их объявления.

Visual Studio 2017
Visual Studio 2017

Заключение

Мы только начали вникать в устройство виртуальных функций на C++. На этом пути нас ждет еще много крышесрывающих зигзагов и умопомрачительных моментов! Мы продолжим разбираться с ними в следующей статье.

РЕКОМЕНДУЕМ:
Анализ исполняемых файлов в IDA Pro

Также мы добрались до того момента, когда между x86 и x64 образовалась ощутимая пропасть. Мы уже прошли этап, на котором было удобно разбираться с кодом для 32-разрядной платформы только потому, что в нем короче адреса в дизассемблерном листинге. Дальше (как и в этой статье) код будет более платформенно ориентированным, следовательно, вычисления станут сложнее. Однако, как мы убедимся в дальнейшем, архитектура Win64 навела порядок среди хаоса, допущенного во время перехода с Win16 на Win32.

Понравилась статья? Поделиться с друзьями:
Добавить комментарий