第二十八章:逆向工程中的多线程与同步

多线程程序,说实话,是逆向工程里比较头疼的一类。你单步调试得好好的,突然另一个线程把数据改了,或者程序直接崩溃了。我早年逆向一个网络游戏时,就吃过这个亏——明明找到了关键跳转,改完一运行,游戏直接闪退。后来才明白,人家有个线程在定时检查代码完整性。

这一章,我们就来聊聊怎么对付多线程程序。我会从基础概念讲起,然后深入到实际逆向中如何识别同步机制,最后教你如何绕过那些烦人的线程检查。

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 同名互斥体来干扰程序。

注意: 有些程序会检查互斥体是否存在。如果发现同名互斥体已经存在,就认为程序已经在运行,然后退出。这时候你可以用 Process Explorer 关闭那个互斥体句柄,或者自己创建一个同名互斥体来欺骗程序。

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 掉。

我的建议: 不要直接 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 实战案例:绕过游戏的多线程反调试

我记得有一次,逆向一个韩国网游。它有个线程专门检查调试器。具体做法是:

  1. 主线程创建检查线程
  2. 检查线程每隔 3 秒调用 NtQueryInformationProcess 检查 ProcessDebugPort
  3. 如果发现调试器,就设置一个全局标志
  4. 主线程在每次循环中检查这个标志,如果被设置就退出

我的解法是这样的:

第一步,找到检查线程。在 CreateThread 下断,记录所有线程函数地址。跑一遍游戏,发现有一个线程函数地址是 0x0045A100

第二步,分析这个函数。在 0x0045A100 下断,发现它调用了 NtQueryInformationProcess。我把这个调用 NOP 掉。

第三步,但游戏还是检测到了调试器。说明还有其他检查点。继续分析,发现它还用 ZwSetInformationThread 隐藏线程——嗯,这个函数本身也是反调试的一种。

最后,我把 ZwSetInformationThread 的调用也 NOP 掉,同时把全局标志的地址改成固定值 0。游戏终于不再检测调试器了。

核心思路: 多线程反调试,本质上就是「生产者-消费者」模式。检查线程是生产者,产生「是否被调试」的信息。主线程是消费者,消费这个信息。你要做的,要么干掉生产者(NOP 掉检查线程),要么篡改产品(修改检查结果),要么切断消费链路(NOP 掉主线程的判断)。

28.6 知识体系图

下面这张图,概括了本章的核心逻辑。从识别多线程程序,到定位线程创建函数,再到识别同步机制,最后绕过线程检查——这是一条完整的逆向链路。

多线程逆向工程知识体系 1. 识别多线程程序 2. 定位线程创建函数 3. 识别同步机制 临界区 (Critical Section) 互斥体 (Mutex) 4. 绕过线程检查

说白了,多线程逆向就是一场猫鼠游戏。程序开发者想方设法保护自己的代码,我们逆向工程师则要想办法绕过这些保护。但万变不离其宗——只要你理解了线程的创建、同步和检查机制,就能找到突破口。

我个人觉得,多线程逆向最考验的是耐心。因为线程之间的交互是动态的,你单步调试时,另一个线程可能正在修改内存。所以,多用日志、多用条件断点、多分析调用栈——这些基本功,比任何花哨的技巧都管用。


公众号:蓝海资料掘金营,微信deep3321