Декомпиляция и вызов функции по адресу - Объяснение принципа работы
ОГЛАВЛЕНИЕ
Объяснение принципа работы
В окне OllyDbg нажмите F3 и найдите TutExample.exe. После его открытия появится следующее окно сообщения:
Можно просто нажать кнопку OK и продолжить; это предупреждение появляется из-за особенностей отладочной сборки (собрана в VS2008 IDE, для справки). Щелчок правой кнопкой мыши по главному окну и выбор Искать -> Все межмодульные вызовы выдаст новое окно:
Это окно показывает вызовы всех функций, которые делает исполняемая программа. Видно, что она вызывает printf из MSVCR90D.dll, GetAsyncKeyState из USER32.dll, и т.д. С помощью этого можно определить, где находятся main() и mySecretFunction(…). Следует начать с main() и увидеть, как вызывается mySecretFunction(…). Дважды щелкните по одной из функций GetAsyncKeyState – и OllyDbg покажет адрес, где они вызываются. Щелкните на верхней строке – и OllyDbg покажет следующее:
Так как есть исходный код, можно просто поставить точку останова (выделить строку и нажать F2) на GetAsyncKeyState, так как известно, что она контролирует, вызывается mySecretFunction(…) или нет. Так как не нужно заходить в функцию и смотреть, как USER32.dll реализует ее, ставится точка останова на первой функции GetAsyncKeyState, и продолжается установка точек останова на каждой строке включительно до второй функции GetAsyncKeyState. После завершения получится следующее:
Начинается анализ – нажмите F9 (Выполнить программу). OllyDbg должен сразу дойти до первой точки останова и подсветить строку. Давайте нажимать F2 построчно и смотреть, что программа делает без вмешательства. Она доходит до этой строки:
0041152A 74 1A JE SHORT TutExamp.00411546
и совершает переход. Обратите внимание, что код клавиши для VK_F11 равен 0x7A, и он помещается в стек непосредственно перед вызовом GetAsyncKeyState. Теперь можно изучить, что эта функция делает, или чего не делает. Надо посмотреть, что делают пропущенные строки:
0041152C 6A 04 PUSH 4
0041152E 68 40E20100 PUSH 1E240
00411533 68 EC574100 PUSH TutExamp.004157EC ; ASCII "Это оригинальный текст!"
00411538 8D45 F8 LEA EAX,DWORD PTR SS:[EBP-8]
0041153B 50 PUSH EAX
0041153C E8 D8FAFFFF CALL TutExamp.00411019
00411541 83C4 10 ADD ESP,10
00411544 EB 19 JMP SHORT TutExamp.0041155F
Видно, что три значения помещаются в стек, затем адрес одного загружается в регистр EAX, который затем помещается в стек. Далее вызывается некоторая функция, стек исправляется после возврата управления ею, и продолжается цикл. Это кажется знакомым и на самом деле является разобранной формой:
mySecretFunction(&anArgument, "This is the original text!", 123456, 4);
Четыре параметра помещаются в стек в обратном порядке, так как стек работает как LIFO (последним пришёл, первым вышел), и затем вызывается функция по адресу 0x00411019. Выделите строку вызова и нажмите Enter, чтобы дойти до функции. Отладчик дойдет до следующей строки:
00411019 E9 A2030000 JMP TutExamp.004113C0
это очередной переход на 0x004113C0. Оказывается, что функция расположена не по адресу 0x00411019, а расположена по адресу 0x004113C0. Снова нажмите Enter на этой строке и перейдете к функции по этому адресу. Перейдя к 0x004113C0, вы увидите знакомые вещи, такие как вход функции и выходные инструкции printf. 0x004113C0 – адрес, который надо вызвать в программе, чтобы вызвать эту функцию с пользовательскими аргументами (прежде чем приступить к этому, надо хотя бы изучить, что происходит в функции, и увидеть, как она выглядит разобранная). Их будет легко отследить, так как они отделены вызовами printf. Также, смотря на исходный код, можно его прямо сопоставить с разобранным кодом.
Расшифровка параметров
Примечание: Те, кто знает ассемблер, могут пропустить эту часть, так как функции и объяснения весьма простые.
Прежде чем изучать это, стоит посмотреть на начало функции, где видно:
00413690 55 PUSH EBP
00413691 8BEC MOV EBP,ESP
00413693 81EC C0000000 SUB ESP,0C0
Это обычно называется вводной частью и устанавливает кадр стека для переданных аргументов, локальных переменных, сохраненных регистров и так далее. В конце этой функции видна группа извлеченных из стека регистров:
0041376E 81C4 C0000000 ADD ESP,0C0
00413774 3BEC CMP EBP,ESP
00413776 E8 CAD9FFFF CALL TutExamp.00411145
0041377B 8BE5 MOV ESP,EBP
0041377D 5D POP EBP
Это называется завершающим кодом и восстанавливает состояние стека, так что программа может нормально продолжить выполняться после завершения этой функции. Ко всем параметрам при этом будут обращаться как к чему-то, добавленному в EBP (указатель базы). Переменные, переданные функции в качестве параметров, являются [EBP+0x04*n], где n может быть 1, 2, 3, и т.д. [EBP+0x04] является местом, где адрес возврата находится в стеке, делая [EBP+0x08] первым параметром, [EBP+0x0C] вторым параметром, и так далее, всякий раз добавляя 0x04, чтобы получить следующий параметр. Аргументы являются [EBP+0x04*n], локальные переменные - [EBP-0x04*n]. Пора вернуться к изучению первой части, разыменовывающей param1 и прибавляющей 2008 (0x7D8) к нему. Важные части были объяснены, и в основном все понятно без объяснений, при условии знания мнемоник языка ассемблера. В тексте эти вещи подробно не анализируются, так как комментарии на картинке ясно описывают, что происходит. Начинается с param1, и далее идет до param4. Происходящее объясняется путем комментариев рядом с важными строками в дизассемблировании. Чтобы следовать за точками останова в отладчике, поставьте точку останова на этой строке и начните анализировать:
004113F5 8B45 08 MOV EAX,DWORD PTR SS:[EBP+8]
Почему пропускаются следующие строки:
004113EB 83C4 04 ADD ESP,4
004113EE 3BF4 CMP ESI,ESP
004113F0 E8 50FDFFFF CALL TutExamp.00411145
Потому что эти три строки не нужны в анализе, они лишь производят очистку после вызова printf. Начнем со следующего:
*param1 += 2008;
printf("param1: %i\n", *param1);
printf("param2: %s\n", param2);
Ставится точка останова в 0x00411424:
param3 *= *param1;
printf("param3 (param3 *= *param1): %i\n", param3);
Ставится точка останова в 0x0041143D:
param4 = 0x90;
printf("param4: %i\n", param4);
Ставится точка останова в 0x00411464:
Продвижение
Теперь, когда все было изучено, и есть хорошее понимание, как все выглядит и работает, пора идти дальше. Надо как-то вызвать функции по конкретному адресу памяти в процессе. Нельзя вызвать ее из своего процесса, так как каждый процесс находится в своем собственном виртуальном пространстве. Допустим, что код скомпилирован, исполняемый файл запускается как secret.exe. Также допустим, что ничего неизвестно о виртуальной памяти, и было написано приложение с функцией, указывающей на 0x004113C0, при предположении, что это mySecretFunction(..). При попытке вызвать эту функцию не будут получены нужные результаты (из-за того что процесс находится в другом адресном пространстве). Следовательно, надо инжектировать DLL. Путем этого можно получить доступ к пространству памяти процесса secret.exe. Код этой DLL выглядел бы так.
Написание DLL
#include <span class="code-keyword"><windows.h></span>
DWORD WINAPI MyThread(LPVOID);
DWORD g_threadID;
HMODULE g_hModule;
void __stdcall CallFunction(int&, const char*, DWORD, BYTE);
INT APIENTRY DllMain(HMODULE hDLL, DWORD Reason, LPVOID Reserved)
{
switch(Reason)
{
case DLL_PROCESS_ATTACH:
g_hModule = hDLL;
DisableThreadLibraryCalls(hDLL);
CreateThread(NULL, NULL, &MyThread, NULL, NULL, &g_threadID);
break;
case DLL_THREAD_ATTACH:
case DLL_PROCESS_DETACH:
case DLL_THREAD_DETACH:
break;
}
return TRUE;
}
DWORD WINAPI MyThread(LPVOID)
{
int myInt = 1;
while(true)
{
if(GetAsyncKeyState(VK_F2) & 1)
{
CallFunction(myInt, "My custom text";, 1, 1);
}
else if(GetAsyncKeyState(VK_F3) &1)
break;
Sleep(100);
}
FreeLibraryAndExitThread(g_hModule, 0);
return 0;
}
void __stdcall CallFunction(int& param1, const char* param2, DWORD param3, BYTE param4)
{
typedef void (__stdcall *pFunctionAddress)(int&, const char*, DWORD, BYTE);
pFunctionAddress pMySecretFunction = (pFunctionAddress)(0x004113C0);
pMySecretFunction(param1, param2, param3, param4);
}
Код весьма простой. Создается поток внутри процесса, в который была инжектирована эта DLL, и устанавливается цикл, ждущий нажатия клавиши. После нажатия F2 должна вызываться функция, в свою очередь вызывающая внутреннюю функцию процесса. Однако передаются собственные параметры вместо тех, которые жестко закодированы в оригинальный файл. Функция CallFunction(…) имеет точное такое же объявление, как и mySecretFunction(…), и в ней устанавливается указатель функции. Было выбрано соглашение о вызовах __stdcall, потому что его чаще всего использует компилятор C++. При необходимости можно было бы еще детальней изучить дизассемблированный исполняемый файл и посмотреть, как очищается стек, чтобы установить, используется ли другое соглашение о вызовах __cdecl. В исполняемом файле создается указатель функции на адрес 0x004113C0, являющийся адресом, ранее найденным для mySecretFunction(…). После его нахождения он вызывается с пользовательскими параметрами. Надо протестировать и увидеть. Запуск скомпилированного кода из начала руководства дает следующее:
Это выглядит нормально и является полностью ожидаемым поведением программы. Надо инжектировать DLL и посмотреть, что произойдет при нажатии F2.
Ясно видно, что что-то отличается. Но ожидалось ли это? Надо посмотреть на параметры и выяснить, какими они должны быть согласно коду mySecretFunction(…). Функции было передано 1, “My custom text”, 1, 1. 1 += 2008 = 2009 (правильно). “My custom text” было выведено (правильно). 1 *= 2009 = 2009 (правильно). Param4 установлен равным 0x90 (144 десятичное, правильно). Похоже, что все сработало прекрасно. Далее можно нажать F11, чтобы получить оригинальную функцию, или можно нажать F2 и получить пользовательскую функцию, но ясно видно, что что-то отличается, и что можно решить вызвать эту функцию со своими собственными параметрами.
Передача правильных параметров
Крайне важно быть осторожным с типами данных. Следует всегда выполнять по шагам каждый кусок и смотреть, что он выводит. Если как-то принять param2 за int, вместо const char*, получатся неожиданные результаты или даже аварийное завершение программы из-за проблем с памятью. Просмотр адреса относительно EBP покажет, что param2 хранит символы, тогда как param1, param3 и param4 хранят числа некоторого типа. Следует изучить несколько случаев, когда не обязательно объявляются такие же типы параметров, как и в оригинальной функции, и посмотреть, что произойдет. Необходимо изучить несколько вариантов, а именно:
• Неиспользование const char* в качестве param2.
• Использование разных числовых типов данных для param1/3/4.
Вначале первый случай. Ниже показано, какие строки были найдены и на что они были заменены при проходе сверху вниз кода DLL.
void __stdcall CallFunction(int&, int, DWORD, BYTE);
CallFunction(myInt, 12345, 1, 1);
void __stdcall CallFunction(int& param1, int param2, DWORD param3, BYTE param4)
typedef void (__stdcall *pFunctionAddress)(int&, int, DWORD, BYTE);
После внесения этих изменений, инжекции новой DLL в процесс, можно нажать F11 и увидеть знакомое сообщение. Однако при нажатии F2 программа аварийно завершается прямо в момент вывода param2. Нужно проявлять внимание и работать с правильными типами данных, когда речь идет о двух разных вещах, таких как const char* и числовой тип. Что произойдет, если изменить один из параметров? Завершится ли программа аварийно, если изменить третий параметр с DWORD на BYTE? Краткий ответ – нет, этого не случится (попробуйте сами). Однако из параметров получится неожиданный вывод, что вроде делает вызов функции бесполезным, если он не дает ожидаемого. Вопрос об отсутствии аварийного завершения программы не универсален; это скорее исключение, чем правило. Программы могут или не могут аварийно завершаться в зависимости от того, что делается с параметрами. Данная программа состоит из сложения, умножения и вывода; другие программы могут не так благосклонно принимать BYTE вместо DWORD, или аналогично.