第二十八章:逆向工程中的多线程与同步
多线程程序,说实话,是逆向工程里比较头疼的一类。你单步调试得好好的,突然另一个线程把数据改了,或者程序直接崩溃了。我早年逆向一个网络游戏时,就吃过这个亏——明明找到了关键跳转,改完一运行,游戏直接闪退。后来才明白,人家有个线程在定时检查代码完整性。
这一章,我们就来聊聊怎么对付多线程程序。我会从基础概念讲起,然后深入到实际逆向中如何识别同步机制,最后教你如何绕过那些烦人的线程检查。
28.1 多线程程序的特征识别
拿到一个程序,怎么判断它是不是多线程的?其实有几个明显的信号。
| 特征 | 表现 | 逆向中的线索 |
|---|---|---|
| 多个主窗口 | 程序启动后出现多个窗口 | CreateWindowEx 调用次数 > 1 |
| 后台任务 | 程序在后台持续工作 | CPU 占用率波动,有定时器 |
| 响应迅速 | 界面不卡顿,但后台在计算 | 消息循环与工作线程分离 |
| 反调试行为 | 检测调试器存在 | CreateThread + 定时检查 |
我个人习惯,先用 Process Explorer 看一眼线程数。如果一个游戏有 20 多个线程,那基本可以确定它用了多线程架构。再用 Cheat Engine 附加,看线程列表——嗯,这里要注意,有些线程是系统线程,别搞混了。
28.2 线程创建函数的逆向定位
多线程程序的起点,就是线程创建函数。Windows 下最常见的是 CreateThread,但很多游戏会用 _beginthreadex 或者直接调用 CreateRemoteThread。
在 x64dbg 里,你可以直接搜索这些 API 的调用。但更实用的方法是:下断点。
// 在 x64dbg 中下断点
bp kernel32.CreateThread
bp kernel32.CreateRemoteThread
bp msvcrt._beginthreadex
断下来后,看堆栈。第一个参数是安全属性,第二个是栈大小,第三个是线程函数地址——这个地址就是我们要分析的目标。
std::thread,底层还是调 CreateThread,但参数传递方式变了。这时候我会在 CreateThread 入口处写个日志脚本,记录每次调用的线程函数地址和参数。跑一遍游戏,回头分析日志,效率高很多。
28.3 临界区与互斥体的识别
多线程程序最头疼的就是同步问题。两个线程同时访问同一块内存,数据就乱了。所以开发者会用临界区(Critical Section)或互斥体(Mutex)来保护共享资源。
从逆向角度看,识别这些同步对象,能帮你理解程序的逻辑结构。
28.3.1 临界区(Critical Section)
临界区是用户态同步机制,效率高,但只能用于同一进程内的线程。它的典型 API 调用序列是:
InitializeCriticalSection // 初始化
EnterCriticalSection // 进入
LeaveCriticalSection // 离开
DeleteCriticalSection // 删除
在 Cheat Engine 里,你可以搜索 EnterCriticalSection 的调用。找到后,看它的参数——一个 CRITICAL_SECTION 结构体指针。这个结构体里有个 LockCount 字段,正常是 -1,被锁住时变成 0 或正数。
EnterCriticalSection,然后处理数据,再 LeaveCriticalSection。我直接 NOP 掉这两个调用,结果游戏崩溃了——因为多个线程同时修改同一个缓冲区。后来我改成只 NOP 掉 LeaveCriticalSection,让临界区永远被锁住,其他线程进不来,我就能安全地读取数据了。
28.3.2 互斥体(Mutex)
互斥体是内核态对象,可以跨进程使用。它的 API 是:
CreateMutex // 创建
OpenMutex // 打开
WaitForSingleObject // 等待
ReleaseMutex // 释放
互斥体比临界区慢,但功能更强。比如,你可以用互斥体防止程序重复启动——很多游戏就是这么干的。
识别方法:在 x64dbg 里搜索 CreateMutex,看它的第一个参数——互斥体名称。如果名称是固定的(比如 "Global\\GameMutex"),那你可以用 CreateMutex 同名互斥体来干扰程序。
28.4 绕过线程检查的常用手段
线程检查,说白了就是程序开个线程,定时检查某些条件是否满足。比如检查代码是否被修改、调试器是否附加、内存断点是否触发等。
我遇到过最狠的一个游戏,开了 5 个线程互相监控。一个线程负责检查主线程的代码段,另一个检查第一个线程的栈,第三个检查前两个的完整性……跟套娃似的。
28.4.1 方法一:NOP 掉线程创建
最简单粗暴的方法。找到 CreateThread 调用,把整个调用 NOP 掉。这样检查线程根本不会启动。
但有个问题:如果这个线程是功能线程(比如处理网络数据),NOP 掉后游戏功能会异常。
28.4.2 方法二:修改线程函数入口
找到线程函数地址后,把它的第一条指令改成 ret(0xC3)。这样线程一启动就立即返回,什么都不做。
// 在 Cheat Engine 中
// 找到线程函数地址,比如 0x00401000
// 写入字节 C3
[00401000] + 0: C3
这个方法比 NOP 掉 CreateThread 更优雅。因为线程还是创建了,但什么也不做。程序的其他部分不会因为线程缺失而崩溃。
28.4.3 方法三:修改检查条件
有些线程检查不是一直运行的,而是每隔几秒检查一次。比如:
while (true) {
Sleep(5000); // 每 5 秒检查一次
if (CheckIntegrity() == FALSE) {
TerminateProcess(GetCurrentProcess(), 0);
}
}
这时候,你可以把 Sleep 的参数改大,比如改成 0x7FFFFFFF(约 24 天)。或者把 TerminateProcess 的调用 NOP 掉。
TerminateProcess。因为有些程序会记录异常日志,或者通过网络上报。更好的做法是修改判断条件,让 CheckIntegrity 永远返回 TRUE。
28.4.4 方法四:HOOK 线程检查函数
如果程序把检查逻辑封装在某个函数里,你可以 HOOK 这个函数。比如:
// 原函数
BOOL CheckThread() {
// 各种检查...
return FALSE; // 发现异常
}
// 我们的 HOOK
BOOL MyCheckThread() {
return TRUE; // 永远说没问题
}
在 Cheat Engine 里,可以用 Auto Assembler 实现 HOOK。或者用 x64dbg 的脚本功能。
28.5 实战案例:绕过游戏的多线程反调试
我记得有一次,逆向一个韩国网游。它有个线程专门检查调试器。具体做法是:
- 主线程创建检查线程
- 检查线程每隔 3 秒调用
NtQueryInformationProcess检查ProcessDebugPort - 如果发现调试器,就设置一个全局标志
- 主线程在每次循环中检查这个标志,如果被设置就退出
我的解法是这样的:
第一步,找到检查线程。在 CreateThread 下断,记录所有线程函数地址。跑一遍游戏,发现有一个线程函数地址是 0x0045A100。
第二步,分析这个函数。在 0x0045A100 下断,发现它调用了 NtQueryInformationProcess。我把这个调用 NOP 掉。
第三步,但游戏还是检测到了调试器。说明还有其他检查点。继续分析,发现它还用 ZwSetInformationThread 隐藏线程——嗯,这个函数本身也是反调试的一种。
最后,我把 ZwSetInformationThread 的调用也 NOP 掉,同时把全局标志的地址改成固定值 0。游戏终于不再检测调试器了。
28.6 知识体系图
下面这张图,概括了本章的核心逻辑。从识别多线程程序,到定位线程创建函数,再到识别同步机制,最后绕过线程检查——这是一条完整的逆向链路。
说白了,多线程逆向就是一场猫鼠游戏。程序开发者想方设法保护自己的代码,我们逆向工程师则要想办法绕过这些保护。但万变不离其宗——只要你理解了线程的创建、同步和检查机制,就能找到突破口。
我个人觉得,多线程逆向最考验的是耐心。因为线程之间的交互是动态的,你单步调试时,另一个线程可能正在修改内存。所以,多用日志、多用条件断点、多分析调用栈——这些基本功,比任何花哨的技巧都管用。