前言
著名的迪奥布兰多先生曾说过“人类是有极限的。”
确实如此,但也正因为我们不能飞,所以才造出了飞机。正因为我们跑的不快,所以才造出了汽车。人类确实存在极限,但人的学习能力却是无限的。而作为一个正在入门逆向萌新的来说,正确认识自身的极限和意识到学习的无穷是很重要的。
为什么这么说呢。回想几个星期前,笔者还在苦苦做论坛上总结的120个CM。每当破解并分析了一个CM感到满足的同时,也为自己耗费长时间去破解分析CM的行为是否值得而感到疑惑,似乎就在原地踏步,而没有学到新的知识。
后来我才意识到,这正是目前自身的极限。缺乏C++编程知识,缺乏系统的API知识,缺乏常规破解手段的知识等等。所以笔者才决定突破自身的极限,学习《逆向工程核心思想》一逆向书籍,才有了今天这篇文章。
可这谈何容易呢?要想突破极限就必须付出相应的努力,去提高自己极限的程度。笔者必须学习C++,必须跟着书籍编写win32程序。当我们知识储备扩展后,极限的程度也就高了,而只有不断提高自身的极限,或许才能成为真正的“大师”吧。
准备工作
本篇文章从原理和实现过程两个角度总结书中API钩取一章节,笔者也是萌新。因此难免会有令人困惑和误解甚至错误的地方。倘若存有疑问请务必留言指教。
为了读者能有更好的阅读体验,最好但不一定必要具备以下知识和工具:
- C++编程基础
- 到微软官方查看文档的能力
- 用于编程的集成环境工具visual studio
- 用于查看进程PID的监控软件Process Explorer
- 文章附带的附件
再次提醒一下,就算读者没有C++编程基础,也可浏览此文到原理部分,实践部分若无编程基础可忽略不看。当熟知原理以及记住几个关键的函数过后,全文核心内容基本可算基本掌握。
API钩取的作用
我们为什么要做API钩取,它有什么作用。在描述原理之前,这是我们必须要弄清楚的问题。如同我们去逆向分析软件,都会问自己为什么会这么做。可能是兴趣使然,可能是好玩,也可能是为了钻研等等。为什么很重要,因为它是我们唯一的学习动力。
API钩取它让我们能够直接修改程序中的流程或代码。说的通俗易懂一点,它可以让我们在别人的程序里为所欲为。
例如我们可以直接修改弹窗的标题或内容;又如可以改变我们输入。只要我们知道要修改的地址,我们就可以让他断下来,然后进行各种操作,来达到我们的目的。如果用过IDA或者olldbg的读者一定会发现,这不正是这些调试器的功能吗?
原理
那到底要怎么做呢?为什么别人写的程序本来好好的却能够突然停下来让你操作,这难道是什么魔法吗?
然而并不是,众所周知Window的编程是函数编程,说的明白一点,程序员在写诸如EXE的Window程序时,都会去使用微软早已做好的函数。而API钩取,也正是使用微软提供的函数去拦截程序使用微软的函数(有点拗口)。有点以毒攻毒的味道呢。
当一个程序被注册了调试程序,他们关系就会发生3600°的变化。本来互不干扰的俩,被注册之后只要产生了调试事件,它就会过问调试自己的程序,把自己的控制权交给调试者。而我们正是利用这个原理,注册成为别人的程序的调试者,再促使被调试程序产生断点调试事件,来获得其生死大权。
如何促使被调试程序产生断点调试事件呢?这里有两个关键点:
- 当程序被注册调试器成功后,会发送一个调试事件来告诉调试者你成功了。
- 当被调试的程序遇到16进制为CC的指令就会产生中断的调试事件。
结合这两点,我们可以在注册成功的时候,改写相应的地址为CC,使其下次遇到CC时触发中断调试事件。
这样我们接收CC的中断调试事件后便可更改被调试程序的相关数据。
实践
实践步骤需要有C++的基础知识,否则可能会难以下咽,没有C++的基础可以跳过。
整个过程如下:
附加要修改的程序使目标程序成为被调试者——>收取附加成功消息——>找到要修改的地址并下断点——>触发断点——>收到异常,程序断下——>实现自己的操作(修改数据)——>让程序继续运行
步骤一:使目标程序成为被调试者
为什么要先附加程序,使其成为被调试者?从流程上讲因为他是必须的,就像有了加法才有乘法,有了减法才有除法一样。
而从原理上讲,附加程序就是向目标程序注册成为调试器,只有拥有调试与被调试的关系后,每当目标程序产生调试事件(如断点产生的异常)后才会报告给调试者。因此我们附加程序,是为了获得目标程序产生异常而造成的空隙,这样我们才有修改数据等操作的机会。
从代码中看,使用DebugActiveProcess
函数,同时传递目标程序PID作为参数,来使特定的程序成为被调试者,而自己的程序成为调试者。以下为函数文档
BOOL DebugActiveProcess(
DWORD dwProcessId
);
步骤二:收取附加成功消息
为了给特定地址下断点,我们需要合适的机会,而这个机会便是程序被附加成功的时候。因此我们只要在接收到附加成功的事件后进行断点操作即可。
同样,贴心的微软提供了一个接收调试事件的方法给我们:
BOOL WaitForDebugEvent(
LPDEBUG_EVENT lpDebugEvent, // 接收调试事件存储的地方
DWORD dwMilliseconds // 等待事件的时间,可设置永久
);
WaitForDebugEvent
函数会接收调试事件,例如附加成功,遇到断点产生异常等。那我们如何才知道哪个事件是附加成功呢?
当事件接收成功后,会将一个叫DEBUG_EVENT 的结构体存储在函数的第一个参数lpDebugEvent中,这个结构体的结构如下:
typedef struct _DEBUG_EVENT {
DWORD dwDebugEventCode; //调试事件各类代码
DWORD dwProcessId; //进程ID
DWORD dwThreadId;
union {
EXCEPTION_DEBUG_INFO Exception;
CREATE_THREAD_DEBUG_INFO CreateThread;
CREATE_PROCESS_DEBUG_INFO CreateProcessInfo; //附加成功后产生
EXIT_THREAD_DEBUG_INFO ExitThread;
EXIT_PROCESS_DEBUG_INFO ExitProcess;
LOAD_DLL_DEBUG_INFO LoadDll;
UNLOAD_DLL_DEBUG_INFO UnloadDll;
OUTPUT_DEBUG_STRING_INFO DebugString;
RIP_INFO RipInfo;
} u;
} DEBUG_EVENT, *LPDEBUG_EVENT;
通过dwDebugEventCode的事件代码我们能判断当前事件的类型,而附加成功的代码是CREATE_PROCESS_DEBUG_EVENT,当产生这个事件,CreateProcessInfo中的另一个结构体——CREATE_PROCESS_DEBUG_INFO ,它储存着一些关于目标程序的详细信息。
是不是看的头晕眼花,简单来说,我们只要使用WaitForDebugEvent
来接收调试事件,用其结果中的dwDebugEventCode来判断是否为附加成功。最后再用其结果产生的CreateProcessInfo来初始化一些设置:
DEBUG_EVENT event;
while(WaitForDebugEvent(&event, INFINITE)){ // 循环获得调试事件
DWORD code = event.dwDebugEventCode;
switch (code)
{
case CREATE_PROCESS_DEBUG_EVENT:
//附加成功后设置断点
onCreateProEvent(&event);
break;
case EXCEPTION_DEBUG_EVENT:
//断点触发后修改数据
onExceptionEvent(&event);
break;
case EXIT_PROCESS_DEBUG_EVENT:
//退出
break;
}
}
步骤三:找到要修改的地址下断点
从上面的代码注释可以看到,在判断为附加成功的事件后,调用了一个onCreateProEvent的函数。这是微软提供的吗?当然不是,这是笔者自己写的:
LPVOID address;
void onCreateProEvent(LPDEBUG_EVENT event) {
//设置断点,获取断点地址,因为是系统DLL的函数因此与目标进程地址一致
address = GetProcAddress(hmod, "MessageBoxA");
BYTE bp = 0xCC;
memcpy_s(&ori, sizeof(event->u.CreateProcessInfo), &event->u.CreateProcessInfo, sizeof(event->u.CreateProcessInfo));
if (!ReadProcessMemory(event->u.CreateProcessInfo.hProcess, address, &oribyte, sizeof(BYTE), NULL)) {
//读取原地址开头
cout << "读取目标进程内存失败";
return;
}
if (!WriteProcessMemory(event->u.CreateProcessInfo.hProcess, address, &bp, sizeof(BYTE), NULL))
{
//写入CC
cout << "写入目标进程内存失败";
return;
}
}
首先我们要获取地址。比如笔者想修改弹窗的标题,那我们就要知道目标程序调用弹窗的代码地址,而当前已知目标程序调用弹窗的函数名叫MessageBoxA
。利用系统函数地址不变的原理,使用GetProcAddress
函数获取其弹窗函数地址:
address = GetProcAddress(hmod, "MessageBoxA");
然后我们只要在这地址下断点就行了。这样当目标程序调用弹窗函数时,便会产生调试事件并断下。
可我们要如何在这地址下断呢?其实很简单,我们只要把这地址的首部替换成0xCC即可。这是因为0xCC在汇编中代表的是一个int3中断指令,当程序遇到它时会报告给调试者
例如我们通过GetProcAddress
获得MessageBoxA
的地址为76EA13D0,那我们下断点后就断成了:CCEA13D0了。
为了修改数据后能让程序正常运行,同时还要保存原地址开头的76,用来复原。
步骤四:触发断点
触发断点很简单,如果像笔者一样是断在弹框处,那只要让目标程序弹出弹窗即可。
步骤五:接收断点调试事件
同样的,我们接收断点调试事件也是使用步骤二的方法WaitForDebugEvent
函数,只不过这次的调试事件类型为EXCEPTION_DEBUG_EVENT
DEBUG_EVENT event;
while(WaitForDebugEvent(&event, INFINITE)){ // 循环获得调试事件
DWORD code = event.dwDebugEventCode;
switch (code)
{
case CREATE_PROCESS_DEBUG_EVENT:
//附加成功后设置断点
onCreateProEvent(&event);
break;
case EXCEPTION_DEBUG_EVENT:
//断点触发后修改数据
onExceptionEvent(&event);
break;
case EXIT_PROCESS_DEBUG_EVENT:
//退出
break;
}
}
在接收到类型为EXCEPTION_DEBUG_EVENT的调试事件后,结果里会产生一个叫EXCEPTION_DEBUG_INFO的结构体:
typedef struct _EXCEPTION_DEBUG_INFO {
EXCEPTION_RECORD ExceptionRecord;
DWORD dwFirstChance;
} EXCEPTION_DEBUG_INFO, *LPEXCEPTION_DEBUG_INFO;
然后里面又会有一个叫EXCEPTION_RECORD的结构体(套娃)
typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD *ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD;
我们需要用到ExceptionCode来判断是不是我们的断点事件,然后再用ExceptionAddress来判断是不是我们断下的窗口地址:
if(ExceptionRecord == EXCEPTION_BREAKPOINT) {
if(ExceptionAddress == address) {
...修改窗口标题
}
}
步骤六:实现自己的操作
从上面代码可以看到,程序断点调试事件收到后会调用onExceptionEvent函数,这个也是自己书写的函数,他的内容便是修改弹窗标题。
那要如何获得弹窗的标题呢?我们可以使用GetThreadContext
来进行获取CONTEXT
结构体,再从其中来获取我们的标题。
对照MessageBoxA文档:
int MessageBoxA(
HWND hWnd,
LPCSTR lpText,
LPCSTR lpCaption,
UINT uType
);
观察文档可以看到,我们要的标题在第三个参数(lpCaption)。在提取标题前我们必须知道一个汇编的知识:ESP代表调用栈的栈顶,当调用一个函数时,根据调用约束的不同,对函数的参数进行不同方式的传递。而C/C++的默认约定便是将参数压入栈。
听不懂不要紧,我们只要知道ESP+4是第一个参数,ESP+8是第二个参数,ESP+C就是我们要的标题。
而所谓的ESP我们可以从CONTEXT
结构体获得:
CONTEXT context;
context.ContextFlags = CONTEXT_CONTROL;
GetThreadContext(ori.hThread, &context);
DWORD ptitle;
ReadProcessMemory(ori.hProcess, (LPCVOID)(context.Esp + 0xC), &ptitle, sizeof(DWORD), NULL);
然后通过WriteProcessMemory
更改标题即可:
CStringA newTitle = "z";
WriteProcessMemory(ori.hProcess, (LPVOID)ptitle, newTitle.GetBuffer(), titleSize, NULL)
最后我们要把更改成CC了的地址恢复为原样,并把EIP复原。因为EIP的值指示着当前代码的地址,地址改了当然EIP也要改:
WriteProcessMemory(ori.hProcess, address, &oribyte, sizeof(byte), NULL);
context.Eip = (DWORD)address; // EIP复原
SetThreadContext(ori.hThread, &context);
最终步骤:让程序继续运行
当调试事件发生后,程序会断下来。而我们为了在修改程序后还能继续运行,必须通知程序没事了你继续吧。因此我们可以调用ContinueDebugEven函数来通知:
BOOL ContinueDebugEvent(
DWORD dwProcessId,
DWORD dwThreadId,
DWORD dwContinueStatus
);
结果
例子是将弹框标题t改成z:
函数列表
以下为一些较为关键的函数(仅名称):
DebugActiveProcess——附加程序,成为目标程序的调试者
WaitForDebugEvent——接收调试事件
GetProcAddress——获取要下断的目标地址
GetThreadContext——获取上下文,以此来获取对应数据,如弹窗标题内容等。
SetThreadContext——上下文设置,用来恢复原来的地址
ContinueDebugEvent——让程序继续运行
ReadProcessMemory——读取目标相关数据
WriteProcessMemory——更改目标数据,如写入CC断点,修改弹窗标题,内容等。
VirtualProtectEx——更改虚拟内存权限,若无法写入数据可使用
总结
随着学习的不断深入,知识的难度越来越难是理所当然的。在写这篇文章的时,如何去用简洁的语言去描述过程,并让那些没有编程基础的读者也能看的明白就成为了一个问题。因为知识难度越高,解释中用到的其他的知识铺垫也会越来越多,这就会陷入一个“不断解释”的问题。
例如给别人解释2x3的意义,我们可以用加法来告诉他2x3就是2个3相加或者3个2相加。但这前提是他必须懂得加法。
那如果说我解释的东西用了n个知识铺垫呢?那我是不是得解释这n个知识。所以说,倘若读者不太看得懂这篇文章那只有两种可能:
- 笔者文笔不好
- 这n个的知识铺垫的缺失
所以说只有不断提高自己,才能向更高的层次进发。
问题:
当我们改写标题的时候,如果新标题的长度超过了4个字节,会使新标题溢出到弹窗内容区:
如上图所示,原标题为t,原内容为z。写入新标题I am title title title title后内容也变成了title tile tile tile。
这是为什么呢?因为在虚拟内存当中,弹窗的标题和内容是连在一起的:
因此标题过长会覆盖到隔壁的内容区域。
可如果我们想用一段任意长的内容替代它而不造成溢出,该怎么做呢?笔者想过是否可以替换掉指向标题的地址:
可一直没成功,如有解决方法希望读者可以留言指点!
附件
工程源码及可直接运行的示例https://share.weiyun.com/IHC4yI7k