Если первого встречного программиста спросить, с какой функции начинается выполнение 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-редакция, которую можно халявно использовать целый год
А как быть, если для компиляции исследуемой программы использовался неизвестный или недоступный тебе компилятор? Прежде чем приступать к утомительному ручному анализу, давай вспомним, какой прототип имеет функция WinMain:
1 2 3 4 5 |
int APIENTRY wWinMain( _In_ HINSTANCE hInstance, // Handle to current instance _In_opt_ HINSTANCE hPrevInstance, // Handle to previous instance _In_ LPWSTR lpCmdLine, // Pointer to command line _In_ int nCmdShow) // Show state of window |
Обрати внимание: APIENTRY замещает WINAPI. Во-первых, четыре аргумента — это достаточно много, и в большинстве случаев WinMain оказывается самой «богатой» на аргументы функцией стартового кода. Во-вторых, последний заносимый в стек аргумент — hInstance — чаще всего вычисляется на лету вызовом функции GetModuleHandleW. То есть, если встретишь конструкцию типа CALL GetModuleHandleW, можно с высокой степенью уверенности утверждать, что следующая функция и есть WinMain. Наконец, вызов WinMain обычно расположен практически в самом конце кода стартовой функции. За ней бывает не более двух-трех «замыкающих» строй функций, например __cexit.
Компилили, компилим и будем компилить!
До сего момента мы компилили наши примеры из командной строки:
1 |
cl.exe <имя файла>.cpp /EHcs |
В результате мы получали строгий, очищенный от мишуры машинный код, после дизассемблирования которого в ассемблерном листинге отсутствовали какие бы то ни было комментарии и внятные названия функций. Теперь мы будем строить наши приложения прямиком из среды разработки (это по-прежнему 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 дала аргументам осмысленные имена:
1 2 3 4 5 6 7 8 9 10 11 12 |
.text:000000014000150A mov r8, rax ; lpCmdLine .text:000000014000150D mov r9d, ebx ; nCmdShow .text:0000000140001510 xor edx, edx ; hPrevInstance .text:0000000140001512 lea rcx, cs:140000000h ; hInstance .text:0000000140001519 call wWinMain .text:000000014000151E mov ebx, eax .text:0000000140001520 call __scrt_is_managed_app .text:0000000140001525 test al, al .text:0000000140001527 jz short loc_140001579 .text:0000000140001529 test dil, dil .text:000000014000152C jnz short loc_140001533 .text:000000014000152E call _cexit_0 ; Завершение приложения |
Но как понять, что находится по адресу cs:140000000h? IDA в комментарии говорит, что там должен быть аргумент hInstance:
1 |
lea rcx, cs:140000000h ; hInstance. |
Ассемблерная команда lea позволяет получить текущий адрес источника (второй операнд). Значит, получение hInstance должно выполняться через косвенный вызов функции GetModuleHandleW.
Если опустить взгляд на пару строчек ниже вызова WinMain, мы увидим вызов __scrt_is_managed_app, войдя в который обнаружим вызов нужной функции:
1 2 3 |
.text:0000000140001C88 sub rsp, 28h .text:0000000140001C8C xor ecx, ecx ; lpModuleName .text:0000000140001C8E call cs:__imp_GetModuleHandleW |
И уже последняя возвращает дескриптор модуля. Но не всегда это выглядит столь же просто. Многие разработчики, пользуясь наличием исходных текстов startup-кода, модифицируют его (подчас весьма значительно). В результате выполнение программы может начинаться не с WinMain, а с любой другой функции. К тому же теперь стартовый код может содержать критические для понимания алгоритма операции (например, расшифровщик основного кода)! Поэтому всегда хотя бы мельком следует изучить startup-код: не содержит ли он чего-нибудь необычного?
Аналогичным образом обстоят дела и с динамическими библиотеками — их выполнение начинается вовсе не с функции DllMain (если она, конечно, вообще присутствует в DLL), а по умолчанию с __DllMainCRTStartup. Впрочем, разработчики подчас изменяют умолчания, назначая ключом /ENTRY ту стартовую функцию, которая им нужна.
Строго говоря, неправильно называть DllMain стартовой функцией. Она вызывается не только при загрузке DLL, но и при выгрузке, и при создании либо уничтожении нового потока подключившим ее процессом.
Получая уведомления об этих событиях, разработчик может предпринимать некоторые действия (например, подготавливать код к работе в многопоточной среде). Весьма актуален вопрос: имеет ли все это значение для анализа программы? Ведь чаще всего требуется проанализировать не всю динамическую библиотеку целиком, а исследовать работу некоторых экспортируемых ею функций.
Если DllMain выполняет какие-то действия (скажем, инициализирует переменные), то остальные функции, на которые распространяется влияние этих переменных, будут содержать прямые ссылки на них, ведущие к DllMain. Таким образом, не стоит «вручную» искать DllMain — она сама себя обнаружит!
Хорошо, если бы всегда это было так! Но жизнь сложнее всяких правил. Вдруг в DllMain находится некий деструктивный код или библиотека в дополнение к основной своей деятельности шпионит за потоками, отслеживая их появление? Тогда без непосредственного анализа ее кода не обойтись.
Обнаружить DllMain на порядок труднее, чем WinMain. Если ее не найдет IDA — пиши пропало. Во-первых, прототип DllMain достаточно незамысловат и не содержит ничего характерного:
1 2 3 4 5 |
BOOL APIENTRY DllMain( HMODULE hModule, // Handle to DLL module DWORD ul_reason_for_call, // Reason for calling function LPVOID lpReserved // Reserved ) |
А во-вторых, ее вызов идет из самой гущи довольно внушительной функции __DllMainCRTStartup, так что найти именно тот CALL, который нам нужен, нет никакой возможности. Впрочем, некоторые зацепки все-таки есть. Например, при неудачной инициализации DllMain возвращает FALSE, и код __DllMainCRTStartup обязательно проверит это значение, в случае чего прыгая аж к концу функции. Подобных ветвлений в теле стартовой функции не так уж много, и обычно только одно из них связано с функцией, принимающей три аргумента.
Для проведения следующего эксперимента в Visual Studio 2017 создай простую динамическую библиотеку и откомпилируй ее релизную версию под платформу x64. Затем загрузи ее в IDA. Следующий листинг призван показать идентификацию DllMain по коду неудачной инициализации:
1 2 3 4 5 6 7 8 9 10 11 |
.text:000000018000131B mov r8, rsi ; lpvReserved .text:000000018000131E mov edx, edi ; fdwReason .text:0000000180001320 mov rcx, r14 ; hinstDLL .text:0000000180001323 call DllMain .text:0000000180001328 mov ebx, eax .text:000000018000132A mov rsp+58h+var_28], eax .text:000000018000132E test eax, eax .text:0000000180001330 jz short loc_18000135B .text:0000000180001332 mov rax, cs:_pRawDllMain .text:0000000180001339 test rax, rax .text:000000018000133C jnz short loc_180001347 |
Вверху приведенного листинга видно, что регистры 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 обычно выглядит так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
.text:0000000140001462 call __p___argv_0 .text:0000000140001467 mov rdi, [rax] .text:000000014000146A call __p___argc_0 .text:000000014000146F mov rbx, rax .text:0000000140001472 call _get_initial_narrow_environment_0 .text:0000000140001477 mov r8, rax .text:000000014000147A mov rdx, rdi .text:000000014000147D mov ecx, [rbx] .text:000000014000147F call main .text:0000000140001484 mov ebx, eax .text:0000000140001486 call __scrt_is_managed_app .text:000000014000148B test al, al .text:000000014000148D jz short loc_1400014E4 .text:000000014000148F test sil, sil .text:0000000140001492 jnz short loc_140001499 .text:0000000140001494 call _cexit_0 |
Чтобы повторить эксперимент, в 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 передается функции как неявный аргумент. Или (что еще хуже) один и тот же указатель используется для вызова двух различных виртуальных функций. Тогда возникает неопределенность, какое именно значение он имеет в данной ветке программы.
Разберем следующий пример (предварительно вспомнив, что, если одна и та же «невиртуальная» функция присутствует и в базовом, и в производном классе, всегда вызывается функция базового класса):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
#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"); }; }; int main(){ printf("main\n"); Base *p = new Base; p->demo(); p->demo_2(); p->demo_3(); p = new Derived; p->demo(); p->demo_2(); p->demo_3(); } |
Результат компиляции примера в общем случае должен выглядеть так:
1 2 3 4 5 6 7 8 |
; int __fastcall main() main proc near ; CODE XREF: __scrt_common_main_seh+107↓p push rbx sub rsp, 20h lea rcx, aMain ; "main\n" call printf mov ecx, 8 ; size call operator new(unsigned __int64) |
В RAX возвращается указатель на выделенный блок памяти. Выделяем восемь байт памяти для экземпляра нового объекта. Объект состоит только из указателя на VTBL. Обрати внимание, что в подавляющем большинстве случаев возвращаемое значение функции помещается в регистр RAX.
1 2 |
mov rbx, rax lea rax, const Base::`vftable' |
В только что созданный объект копируется указатель на виртуальную таблицу класса Base. То, что это именно виртуальная таблица класса Base, можно узнать, проанализировав элементы этой таблицы, — они указывают на члены класса Base. Следовательно, сама таблица есть виртуальная таблица этого класса.
1 |
mov rcx, rbx ; this |
Заносим в RCX указатель на экземпляр объекта (указатель на указатель на BASE_VTBL). Другими словами, передаем вызываемой функции неявный аргумент — указатель this (к слову, поскольку this считается целочисленным аргументом, он всегда помещается в регистр RCX).
1 |
mov [rbx], rax |
По адресу, на который указывает RBX, помещаем указатель на виртуальную таблицу класса Base, не забывая о том, что указатель на виртуальную таблицу одновременно указатель и на первый элемент этой таблицы. А первый элемент виртуальной таблицы содержит указатель на первую (в порядке объявления) виртуальную функцию класса.
1 |
call cs:const Base::`vftable' ; Base::demo(void) ... |
Вот он, вызов виртуальной функции! Чтобы понять, какая именно функция вызывается, мы должны знать значение регистра RAX. Прокручивая экран дизассемблера вверх, мы видим: RAX указывает на BASE_VTBL - - Base:: 'vftable', а первый член BASE_VTBL (см. ниже) указывает на функцию Base::demo. Следовательно:
а) этот код вызывает именно функцию
Base::demo;
б) функция
Base::demo — это виртуальная функция.
1 |
mov rdx, [rbx] |
Заносим в RDX указатель на первый элемент виртуальной таблицы класса Base.
1 |
mov rcx, rbx |
Заносим в RCX указатель на экземпляр объекта: это неявный аргумент функции — указатель this.
1 |
call qword ptr [rdx+8] |
Еще один вызов виртуальной функции! Чтобы понять, какая именно функция вызывается, мы должны знать содержимое регистра RDX. Прокручивая экран дизассемблера вверх, видим, что он указывает на BASE_VTBL, а RDX+8, стало быть, указывает на второй элемент виртуальной таблицы класса Base. Он же, в свою очередь, указывает на функцию Base::demo_2.
1 2 |
lea rcx, aNonVirtualBase ; "Non virtual BASE DEMO 3\n" call printf |
А вот вызов невиртуальной функции. Обрати внимание — он происходит так же, как вызов обычной С-функции. Заметь, эта функция встроенная, так как объявлена непосредственно в самом классе и вместо ее вызова выполняется подстановка кода.
1 2 |
mov ecx, 8 ; size call operator new(unsigned __int64) |
Далее идет вызов функций класса Derived. Не будем здесь подробно его комментировать, сделай это самостоятельно. Вообще же, класс Derived понадобился только для того, чтобы показать особенности компоновки виртуальных таблиц.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
mov rbx, rax lea rax, const Derived::`vftable' mov rcx, rbx ; this mov [rbx], rax call cs:const Derived::`vftable' mov rdx, [rbx] mov rcx, rbx call qword ptr [rdx+8] lea rcx, aNonVirtualBase ; "Non virtual BASE DEMO 3\n" call printf ; Обрати внимание — вызывается функция demo_3 базового, ; а не производного класса! xor eax, eax add rsp, 20h pop rbx retn main endp |
Искаженные имена
Если у тебя в окне дизассемблера имена функций выглядят немного неожиданно (с примесью знаков вопроса и коммерческого at — ?, @) и это доставляет дискомфорт, можешь отключить замангливание (кромсание) имен. Для этого в IDA открой окно Demangled C++ names ( Options -> Demangled names…), поставь переключатель на пункт Names и жми OK. После этой нехитрой операции имена функций примут привычный вид.
Вот как выглядят наши функции:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
; void __fastcall Base::demo(Base *this) public: virtual void Base::demo(void) proc near lea rcx, _Format ; "BASE\n" jmp printf public: virtual void Base::demo(void) endp ; void __fastcall Base::demo_2(Base *this) public: virtual void Base::demo_2(void) proc near lea rcx, aBaseDemo2 ; "BASE DEMO 2\n" jmp printf public: virtual void Base::demo_2(void) endp ; void __fastcall Derived::demo(Derived *this) public: virtual void Derived::demo(void) proc near lea rcx, aDerived ; "DERIVED\n" jmp printf public: virtual void Derived::demo(void) endp ; void __fastcall Derived::demo_2(Derived *this) public: virtual void Derived::demo_2(void) proc near lea rcx, aDerivedDemo2 ; "DERIVED DEMO 2\n" jmp printf public: virtual void Derived::demo_2(void) endp |
Таблицы виртуальных методов:
1 2 3 4 5 6 7 8 9 |
dq offset const Derived::`RTTI Complete Object Locator' ; void (__fastcall *const Derived::`vftable'[3])() const Derived::`vftable' dq offset Derived::demo(void) dq offset Derived::demo_2(void) dq offset const Base::`RTTI Complete Object Locator' ; void (__fastcall *const Base::`vftable'[3])() const Base::`vftable' dq offset Base::demo(void) dq offset Base::demo_2(void) |
Обрати внимание: виртуальные таблицы «растут» снизу вверх в порядке объявления классов в программе, а элементы виртуальных таблиц «растут» сверху вниз в порядке объявления виртуальных функций в классе. Конечно, так бывает не всегда. Порядок размещения таблиц и их элементов нигде не декларирован и целиком лежит на «совести» компилятора. Но на практике большинство из них ведет себя именно так. Сами же виртуальные функции располагаются вплотную друг к другу в порядке их объявления.
Заключение
Мы только начали вникать в устройство виртуальных функций на C++. На этом пути нас ждет еще много крышесрывающих зигзагов и умопомрачительных моментов! Мы продолжим разбираться с ними в следующей статье.
РЕКОМЕНДУЕМ:
Анализ исполняемых файлов в IDA Pro
Также мы добрались до того момента, когда между x86 и x64 образовалась ощутимая пропасть. Мы уже прошли этап, на котором было удобно разбираться с кодом для 32-разрядной платформы только потому, что в нем короче адреса в дизассемблерном листинге. Дальше (как и в этой статье) код будет более платформенно ориентированным, следовательно, вычисления станут сложнее. Однако, как мы убедимся в дальнейшем, архитектура Win64 навела порядок среди хаоса, допущенного во время перехода с Win16 на Win32.