逆向工程和按地址进行函数调用






5.00/5 (133投票s)
是否曾想知道如何调用其他进程的内部函数?本文正是关于此的——您将学习如何在已知源代码的项目中读取反汇编代码,然后将这些知识应用于没有源代码的应用程序。
目录
以下是按相互关系分组的主题
引言
本文的目的是向您展示如何通过自己的程序调用另一个程序的函数。本教程将分为一系列步骤,先提供一个通用示例,然后将这些知识应用于实际程序。
所需工具
本文主要使用调试器(OllyDbg可在此处免费下载这里)和Winject DLL注入器(可在线免费下载,并包含在callbin.zip中)。如果您想编译DLL的代码,则需要一个C++编译器。编译测试应用程序并尝试在其上工作很可能会因为编译器设置而产生不同的结果,因此最好只使用callbin.zip中包含的那个。
为什么这样做?
逆向工程进程内部函数的调用有很多原因。例如,如果您曾见过任何类型的游戏附加组件或“mod”,它们可能使用这种技术来调用进程(游戏)的内部函数以在屏幕上显示文本。也许,您想扩展程序的快捷键功能。也许您有兴趣调用游戏中的“获胜”功能?无论如何,这种技术有很多广泛的应用,此处只提及了部分。本教程推荐的唯一知识是x86汇编知识以及一些Win32 API知识。
编写我们的测试应用程序
让我们从一个预先编写的应用程序开始,然后尝试对其进行逆向工程,看看这个函数是如何被调用的。我们将制作一个简单的应用程序来进行一些算术运算,并通过一个函数输出值。这是我们的测试应用程序
#undef UNICODE
#include <windows.h>
#include <stdio.h>
void mySecretFunction(int* param1, const char* param2,
DWORD param3, BYTE param4)
{
printf("----------Function Entry----------\n");
*param1 += 2008;
printf("param1: %i\n", *param1);
printf("param2: %s\n", param2);
param3 *= *param1;
printf("param3 (param3 *= *param1): %i\n", param3);
param4 = 0x90;
printf("param4: %i\n", param4);
printf("----------Function Exit----------\n");
}
int main(void)
{
int anArgument = 123;
for(;;)
{
if(GetAsyncKeyState(VK_F11) & 1)
mySecretFunction(&anArgument,
"This is the original text!",
123456, 4);
else if(GetAsyncKeyState(VK_F1) & 1)
break;
}
return 0;
}
这只是我快速编写的一个函数,它接受四个参数(一个按引用传递,三个按值传递)。编写自己的函数并对其进行逆向工程的最大优点是您已经知道要寻找什么。当尝试通过您没有源代码的应用程序的地址调用函数时,您应该准备好做大量额外的工作,因为它肯定没有那么容易。我们要做的就是对这个程序的调试构建(包含在callbin.zip中)进行逆向工程,以逐步查看到底发生了什么。为什么是调试构建,而不是禁用优化的发布构建?我个人对此的理由是它可能会使调试器的分析更容易一些,但对于此,这无关紧要。我还直到完成本文才想到这个想法(主要原因)。如果您查看此程序的发布构建,根据编译器优化,mySecretFunction(...)
可能会与main
内联,并且会看起来大相径庭,因为参数可能存储在静态位置。此外,由于函数可能内联,尝试通过地址调用它将是毫无意义的搜索。所以,让我们开始分析,并打开OllyDbg。
了解其工作原理
在 OllyDbg 窗口中,按 F3,找到 TutExample.exe。打开它之后,您会看到这个消息框弹出
您可以直接点击“确定”按钮继续;此警告是由于调试版本(在VS2008 IDE下构建,供参考)的特性而弹出的。右键单击主窗口并选择“搜索”->“所有模块间调用”,我们会看到这个新窗口
此窗口显示了我们的可执行文件所调用的所有函数。我们看到它调用了来自 MSVCR90D.dll 的 printf
,来自 USER32.dll 的 GetAsyncKeyState
等等。通过使用此功能,我们可以找到 main()
和 mySecretFunction(…)
的位置。首先,让我们从 main()
开始,看看 mySecretFunction(…)
是如何被调用的。双击其中一个 GetAsyncKeyState
函数,OllyDbg 会带您到它们被调用的地址。点击最上面的一个,OllyDbg 会带我们到一个看起来像这样的地方
由于我们有源代码,我们可以稍微作弊一下,直接在 GetAsyncKeyState
上设置一个断点(高亮行并按 F2),因为我们知道它控制着我们是否调用 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 "This is the original text!"
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 <windows.h> 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,“我的自定义文本”,1,1。1 += 2008 = 2009(正确)。“我的自定义文本”被输出(正确)。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
,或者类似的情况。
将此应用于实际应用程序
现在,来看一个实际的例子,我们不知道源代码,需要进行一些猜测。尽管我不太喜欢将扫雷或标准 Windows 游戏作为“真实”示例,但我想不到还有什么其他有用的东西像这些游戏一样在桌面上常见,并且这种技术可以用来实现一些真正有用的东西。我现实世界的例子可能是一个大型应用程序或一个具有大量功能的游戏,调用它们的函数可以产生一些非常酷炫的结果。然而,我想这在法律上太有问题了,也会违反 CodeProject 的一些文章提交指南。无论如何,让我们开始我们的扫雷游戏,看看我们可以在哪里应用这种技术。我们的窗口
那么,我们将在哪里应用这项技术呢?就我个人而言,我认为如果我们有一个快捷键可以为我们赢得游戏,那会很好。那么,让我们开始吧;但是,在我们开始之前,我们应该准确地列出我们要做的。我们的目标是
- 找到扫雷游戏用来赢得游戏的函数。
- 查看它接受哪些参数,以及它是如何工作的。
- 编写一个我们可以注入的 DLL,并设置一个热键来为我们调用此函数。
寻找函数
但是,我们如何找到这个神秘函数呢?这里有多种方法,每种方法难度各不相同。我解决这个问题的方法是观察游戏获胜时的行为。我注意到,如果我启用了声音,游戏获胜时会播放声音(或者计时器滴答作响,或者我踩到炸弹)。我们可以利用这些信息追踪播放声音的 API,然后反向工作,找到游戏获胜时执行的函数。所以,在 OllyDbg 中打开游戏,然后按 F9(运行程序)。完成此操作后,您可以按 Ctrl+A 让 OllyDbg 进一步分析代码。我们正在寻找一个播放声音的 API;所以,右键单击主窗口,然后转到“搜索”->“所有模块间调用”。您会看到一个相当大的列表,比示例程序中的列表大。单击“目标”选项卡以按 API 名称按字母顺序排序这些内容。有大量的 API,但有些东西应该会引起我们的注意。
PlaySoundW
听起来很有趣。根据 MSDN,PlaySound
API 的作用如下
PlaySound
函数播放由给定文件名、资源或系统事件指定的声音。(系统事件可以与注册表或 WIN.INI 文件中的声音相关联。)BOOL PlaySound( LPCTSTR pszSound, HMODULE hmod, DWORD fdwSound );
这绝对是我们想要的,所以让我们在所有这三个上设置一个断点。完成此操作后,我们应该回到扫雷游戏主窗口,然后单击一个方块开始游戏。紧接着,我们应该在
01003937 |> FF15 68110001 CALL DWORD PTR DS:[<&WINMM.PlaySoundW>] ; WINMM.PlaySoundW
如果我们按下F9继续运行此程序,我们会看到当计时器每增加1时,都会调用这个特定的函数。如果我们将断点取消并继续,我们会发现当计时器增加时,这个API不再被调用。然而,当您赢得或输掉游戏时,它也不会被调用。这意味着这一个特定的函数负责所有这三件事,并且反向追踪,我们可以看到是什么在调用它。
我们看到我们从 0x01003913 和 0x01003925 有跳转到这个函数。所以,让我们在它们上面设置断点,看看发生了什么。
01003903 |. 68 05000400 PUSH 40005 ; Case 3 of switch 010038FA
01003908 |. FF35 305B0001 PUSH DWORD PTR DS:[1005B30] ; WINMINE.01000000
0100390E |. 68 B2010000 PUSH 1B2
01003913 |. EB 22 JMP SHORT WINMINE.01003937
01003915 |> 68 05000400 PUSH 40005 ; Case 2 of switch 010038FA
0100391A |. FF35 305B0001 PUSH DWORD PTR DS:[1005B30] ; WINMINE.01000000
01003920 68 B1010000 PUSH 1B1
01003925 |. EB 10 JMP SHORT WINMINE.01003937
我们看到这两个段将三个参数压入堆栈,然后调用 PlaySoundW
。如果我们回想一下之前的内容,滴答声的 LPCTSTR
pszSound
参数是 0x1B0。这里,我们有 0x1B2 和 0x1B1。仅仅通过逻辑,您可以猜测这两个对应于爆炸声和胜利声。但是,是哪一个呢?这就是我们要测试的。让我们在每个上面设置一个断点,然后看看。我们注意到,如果我们开始一个新游戏并偶然遇到一个炸弹,我们会在
0100390E |. 68 B2010000 PUSH 1B2
如果我们赢得了游戏,0x1B1 会被压入堆栈。所以,0x1B2 是爆炸声,0x1B1 是胜利声,0x1B0 是滴答声。因此,代码的关键部分是这个
01003915 |> 68 05000400 PUSH 40005 ; Case 2 of switch 010038FA
0100391A |. FF35 305B0001 PUSH DWORD PTR DS:[1005B30] ; WINMINE.01000000
01003920 68 B1010000 PUSH 1B1
01003925 |. EB 10 JMP SHORT WINMINE.01003937
现在,再次反向工作,我们想找到是什么调用了它。如果我们在主窗口中点击 0x01003915,我们会看到这个
注意:我们也可以右键单击命令并转到“查找引用”->“选定命令”(Ctrl+R)。再次向后追溯到 0x010038FE 并调查其周围,我们遇到了这段代码块,其中 0x010038ED 是整个函数的开头
010038ED /$ 833D B8560001 >CMP DWORD PTR DS:[10056B8],3
010038F4 |. 75 47 JNZ SHORT WINMINE.0100393D
010038F6 |. 8B4424 04 MOV EAX,DWORD PTR SS:[ESP+4]
010038FA |. 48 DEC EAX ; Switch (cases 1..3)
010038FB |. 74 2A JE SHORT WINMINE.01003927
010038FD |. 48 DEC EAX
010038FE |. 74 15 JE SHORT WINMINE.01003915
01003900 |. 48 DEC EAX
01003901 |. 75 3A JNZ SHORT WINMINE.0100393D
我们看到 10056B8 中的值与 3 进行比较,我们可以推断出这是 switch
语句的三种情况。[ESP+4]
的值被移动到 EAX
中,然后我们查看它是多少并采取相应的行动。我们还没有完全达到目的,但似乎我们正在接近。让我们转到函数的顶部,看看是什么调用了它。选择该行,然后按 Ctrl+R 查看是什么调用了它。
让我们在所有三个地方设置一个断点,然后开始一个新游戏。我们应该立即遇到这个
0100382B |. E8 BD000000 CALL WINMINE.010038ED
既然我们甚至还没有开始玩,更不用说赢了,我们应该只删除断点并继续。我们继续一两秒钟后,我们会遇到这一行
01003002 |. E8 E6080000 CALL WINMINE.010038ED
这也不是我们想要的。现在我们已经缩小到只有一个了。
010034CF |. E8 19040000 CALL WINMINE.010038ED
如果我们稍微尝试一下,我们可以看到当我们赢得或输掉游戏时,会调用 0x010034CF。现在,我们只需要区分并找出是什么决定了我们是赢还是输。让我们整体看一下这个函数
0100347C /$ 8325 64510001 >AND DWORD PTR DS:[1005164],0
01003483 |. 56 PUSH ESI
01003484 |. 8B7424 08 MOV ESI,DWORD PTR SS:[ESP+8]
01003488 |. 33C0 XOR EAX,EAX
0100348A |. 85F6 TEST ESI,ESI
0100348C |. 0F95C0 SETNE AL
0100348F |. 40 INC EAX
01003490 |. 40 INC EAX
01003491 |. 50 PUSH EAX
01003492 |. A3 60510001 MOV DWORD PTR DS:[1005160],EAX
01003497 |. E8 77F4FFFF CALL WINMINE.01002913
0100349C |. 33C0 XOR EAX,EAX
0100349E |. 85F6 TEST ESI,ESI
010034A0 |. 0F95C0 SETNE AL
010034A3 |. 8D0485 0A00000>LEA EAX,DWORD PTR DS:[EAX*4+A]
010034AA |. 50 PUSH EAX
010034AB |. E8 D0FAFFFF CALL WINMINE.01002F80
010034B0 |. 85F6 TEST ESI,ESI
010034B2 |. 74 11 JE SHORT WINMINE.010034C5
010034B4 |. A1 94510001 MOV EAX,DWORD PTR DS:[1005194]
010034B9 |. 85C0 TEST EAX,EAX
010034BB |. 74 08 JE SHORT WINMINE.010034C5
010034BD |. F7D8 NEG EAX
010034BF |. 50 PUSH EAX
010034C0 |. E8 A5FFFFFF CALL WINMINE.0100346A
010034C5 |> 8BC6 MOV EAX,ESI
010034C7 |. F7D8 NEG EAX
010034C9 |. 1BC0 SBB EAX,EAX
010034CB |. 83C0 03 ADD EAX,3
010034CE |. 50 PUSH EAX
010034CF |. E8 19040000 CALL WINMINE.010038ED
010034D4 |. 85F6 TEST ESI,ESI
010034D6 |. C705 00500001 >MOV DWORD PTR DS:[1005000],10
010034E0 |. 5E POP ESI
010034E1 |. 74 2C JE SHORT WINMINE.0100350F
010034E3 |. 66:A1 A0560001 MOV AX,WORD PTR DS:[10056A0]
010034E9 |. 66:3D 0300 CMP AX,3
010034ED |. 74 20 JE SHORT WINMINE.0100350F
010034EF |. 8B0D 9C570001 MOV ECX,DWORD PTR DS:[100579C]
010034F5 |. 0FB7C0 MOVZX EAX,AX
010034F8 |. 8D0485 CC56000>LEA EAX,DWORD PTR DS:[EAX*4+10056CC]
010034FF |. 3B08 CMP ECX,DWORD PTR DS:[EAX]
01003501 |. 7D 0C JGE SHORT WINMINE.0100350F
01003503 |. 8908 MOV DWORD PTR DS:[EAX],ECX
01003505 |. E8 77E6FFFF CALL WINMINE.01001B81
0100350A |. E8 9BE6FFFF CALL WINMINE.01001BAA
0100350F \> C2 0400 RETN 4
让我们在函数开头到 `return` 语句结束的每一行都设置一个断点。由于此函数在您赢和输时都会被调用,您可以从检查在这些条件下哪些部分被跳过开始。在所有这些行上设置断点并输掉游戏后,我们发现这些行被跳过了。
010034B4 |. A1 94510001 MOV EAX,DWORD PTR DS:[1005194]
010034B9 |. 85C0 TEST EAX,EAX
010034BB |. 74 08 JE SHORT WINMINE.010034C5
010034BD |. F7D8 NEG EAX
010034BF |. 50 PUSH EAX
010034C0 |. E8 A5FFFFFF CALL WINMINE.0100346A
我们还发现,当您输掉游戏时,要执行的函数中的最后一条语句是
010034E1 |. 74 2C JE SHORT WINMINE.0100350F
现在,看看你赢了会发生什么。我们发现函数被完整地执行了。
注意:如果您在调试器中跟踪并且您的游戏停在了这一行
010034ED |. 74 20 JE SHORT WINMINE.0100350F
那是因为此行之后的代码是为了检查您是否有资格进入高分榜。
那么,决定跳转的区别是什么?我们来到了这些行
010034B0 |. 85F6 TEST ESI,ESI
010034B2 |. 74 11 JE SHORT WINMINE.010034C5
在这里,我们正在测试 ESI
是否为 0,如果是,我们执行跳转,跳过处理获胜游戏的部分。换句话说,如果 ESI
为 0,那么我们输了。要查看我们获胜时 ESI
的值,我们可以简单地在该行在 OllyDbg 中检查它的值。
我们看到,当我们获胜时,ESI
的值为 1。那么,ESI
是在哪里设置的呢?如果我们检查函数,我们可以看到在开头附近,有这样一行
01003484 |. 8B7424 08 MOV ESI,DWORD PTR SS:[ESP+8]
传入此函数的第一个参数被移至 ESI
。为什么是 ESP
,而不是像原始示例中那样是 EBP
?这是因为此函数没有像另一个函数那样设置堆栈帧,因此参数通过 ESP
而不是 EBP
访问。我们看到 ESP
的最高值为 +8,并且此函数返回 4,因此我们可以推断它只接受一个参数。我们甚至可以通过检查 0x0100347C 的引用,并在调用之前只看到一个 PUSH
语句来验证这一点。我们已经实现了我们最初三个目标中的两个,所以现在,剩下的就是编写我们的 DLL 来创建一个热键,该热键以 1 作为参数调用此函数。由于我们将此参数移动到 32 位寄存器中,因此我们可以在 DLL 中将其设置为 DWORD
类型(尽管反汇编中是 DWORD PTR
)。如果有人对整个函数的描述感兴趣,下面是我在逐步检查和快速浏览发生的情况时记下的笔记。
编写新的 DLL
在这里,我们可以使用原始函数的 DLL 模板。只需做一些琐碎的更改。新代码如下所示
#include <windows.h>
DWORD WINAPI MyThread(LPVOID);
DWORD g_threadID;
HMODULE g_hModule;
void __stdcall CallFunction(void);
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)
{
while(true)
{
if(GetAsyncKeyState(VK_F3) & 1) //Set F3 as our hotkey
{
CallFunction();
}
else if(GetAsyncKeyState(VK_F4) & 1)
break;
Sleep(100);
}
FreeLibraryAndExitThread(g_hModule, 0);
return 0;
}
void __stdcall CallFunction(void)
{
typedef void (__stdcall *pFunctionAddress)(DWORD);
pFunctionAddress pWinFunction = (pFunctionAddress)(0x0100347C); //Our new address
pWinFunction(1); //Call it with 1 to win the game
}
为了确保万无一失,让我们来测试一下。启动一个新的扫雷实例,并注入 DLL。点击一个方块开始游戏,然后按下 F3 快捷键。结果呢?希望是类似这样的东西。
您会注意到获胜时并不会完全揭示棋盘,但这可以留作读者练习 :-)。
更多资源
历史
- 2008年9月21日 - 文章提交。