逆向工程《使命召唤》反作弊系统
我已经对《使命召唤:黑色行动冷战》进行了一段时间的逆向工程,现在决定分享一些关于游戏内用户模式反作弊系统的研究成果。本文的目的并非鼓励或推广绕过反作弊系统,因此我会对一些内容进行删减。
首先澄清一点,《黑色行动冷战》并没有像《现代战争》(2019)及其后续作品那样使用Ricochet反作弊系统的内核模式组件。本文将反作弊系统称为TAC(Treyarch反作弊系统),因为我逆向的这款游戏是由Treyarch开发的。此外,我提供的函数伪代码会尽量简洁,因为实际的反编译结果通常包含大量冗余代码。新游戏与旧游戏最大的区别在于内核模式驱动,而大部分反作弊代码仍然运行在用户模式,与TAC非常相似。
在深入探讨之前,我们先来看看反作弊系统和游戏是如何被保护的。
Arxan保护工具
- Arxan 是一款用于多款《使命召唤》游戏的混淆/保护工具,尤其是《黑色行动3》之后的版本。它包含了许多功能,使得作弊者和逆向工程师的工作变得更加困难。
运行时可执行文件解密
- 游戏的可执行文件是经过打包和加密的;Arxan在启动过程中插入代码来解包和解密游戏可执行文件。
可执行文件校验和
- Arxan会持续监控游戏可执行文件,检测是否有任何补丁。
- 如果你想了解更多关于这些校验和的内容,momo5502有一篇非常棒的博客文章,可以在这里找到:链接。
- 当Arxan检测到调试器或校验和不匹配时,它会终止进程。
跳转混淆
- Arxan可以将一个函数及其所有指令通过跳转指令(jmp)分隔开。
- 这种方法也有助于隐藏函数的调用来源,它会破坏IDA的分析,需要使用外部工具来梳理这些指令。
push rbp
mov rbp, offset unk_7FF60ECD1310
xchg rbp, [rsp]
push rbx
jmp loc_7FF62B2050A6
loc_7FF62B2050A6:
push rax
mov rbx, [rsp+10h]
mov rax, offset loc_7FF60ECD1622
cmovbe rbx, rax
jmp loc_7FF62BD590D3
loc_7FF62BD590D3:
mov [rsp+10h], rbx
pop rax
pop rbx
retn
loc_7FF60ECD1622:
jmp loc_7FF629D04404
; 等等
- 静态分析这种代码非常困难,尤其是当一个函数被植入了数百个跳转指令时。
入口点混淆
- 在受Arxan保护的游戏中,跟踪入口点非常困难;首先,你会遇到受保护的Arxan代码,它会解包并执行游戏的真正入口点,而这里也可能被植入了跳转混淆,使得理解代码流程变得极其困难。
指针加密
- 长期以来,人们认为这是Arxan的功能,但根据最新的信息,这实际上是Treyarch开发的,并与Infinity Ward共享的技术,或者可能是反过来。
- 重要的指针,如当前游戏的全局变量、实体数组、对象指针等,每次使用前都会进行加密和解密。
- 相同的加密方法有16种变体;当前的PEB(进程环境块)地址实际上决定了使用哪种加密方法。
- 这种方法非常有效,确实会让你的工作变得更加困难。
- 迫使你获取解密后的指针。
- 防止使用Cheat Engine进行指针扫描(当扫描加密的内存地址时,实际的全局值将保存加密后的值,而这个值永远不会被设置为解密后的值;解密后的值始终在栈上)。
- 有几种方法可以获取这些解密后的指针(以下并非全部方法):
- 使用工具跟踪解密指令。
- 在游戏已经解密内存的地方创建钩子。
- 这种方法非常有效,确实会让你的工作变得更加困难。
__forceinline int get_encryption_method()
{
// 这是实际在可执行文件中的代码
// 这个ROL的结果是0x60,即gs[PEB]
// 这些值是生成的,不会总是相同
const auto value = (unsigned __int8)__ROL1__(-127, 230);
auto peb = __readgsqword(value);
return _byteswap_uint64(peb << 33) & 0xF;
}
- 这里只是游戏可执行文件中加密操作的一小部分。
现在我们已经了解了游戏和反作弊系统是如何被保护的,接下来我们可以更深入地探讨。TAC直接嵌入在游戏可执行文件中,不使用任何内核组件,并且在发现调试痕迹时会终止进程。
TAC如何检测监控?
- API钩子检测
- TAC是为Windows设计的,这意味着它将使用Windows特定的API来进行反作弊。
- 这里使用的钩子检测相当基础,目前只检查7种模式。看起来他们只是将之前作弊工具的钩子存根放入其中。
- 注意:每次我提供的示例代码中,调用的每个API都是TAC正在使用的,并且正在检查是否有钩子,并通过它们的运行时哈希查找进行解析。此外,TAC的大部分代码都是高度内联的。
; 第一个存根
push rax
movabs rax,0x0
xchg QWORD PTR [rsp],rax
ret
; 第二个存根
push rbx
movabs rbx,0x0
xchg QWORD PTR [rsp],rbx
ret
; 第三个存根
push rcx
movabs rcx,0x0
xchg QWORD PTR [rsp],rcx
ret
; 第四个存根
push rdx
movabs rdx,0x0
xchg QWORD PTR [rsp],rdx
ret
; 第五个存根
push 0x0
ret
; 第六个存根(这是任何调用,0xE8, 0x0, 0x0, 0x0, 0x0)
call 0x00000
; 第七个存根(这是任何jmp [rip+x], 0xFF, 0x25, 0x00, 0x00, 0x00, 0x00)
jmp QWORD PTR [rip+0]
- 这些检查是如何实现的?汇编中的0x0位置是8字节的,因为这是x64架构。
__forceinline void ac_check_hook(unsigned __int64 address, callback cb)
{
unsigned __int8* current_pos = nullptr;
bool hook_detected = false;
for (current_pos = (unsigned __int8 *)address; *current_pos == 144; ++current_pos)
;
switch (*current_pos)
{
case 0x50u:
if (current_pos[1] == 72
&& current_pos[2] == 184
&& current_pos[11] == 72
&& current_pos[12] == 135
&& current_pos[13] == 4
&& current_pos[14] == 36
&& current_pos[15] == 195)
{
hook_detected = true;
}
break;
case 0x53u:
if (current_pos[1] == 72
&& current_pos[2] == 187
&& current_pos[11] == 72
&& current_pos[12] == 135
&& current_pos[13] == 28
&& current_pos[14] == 36
&& current_pos[15] == 195)
{
hook_detected = true;
}
break;
case 0x51u:
if (current_pos[1] == 72
&& current_pos[2] == 185
&& current_pos[11] == 72
&& current_pos[12] == 135
&& current_pos[13] == 12
&& current_pos[14] == 36
&& current_pos[15] == 195)
{
hook_detected = true;
}
break;
case 0x52u:
if (current_pos[1] == 72
&& current_pos[2] == 186
&& current_pos[11] == 72
&& current_pos[12] == 135
&& current_pos[13] == 20
&& current_pos[14] == 36
&& current_pos[15] == 195)
{
hook_detected = true;
}
break;
case 0x68u:
if (current_pos[5] == 195)
hook_detected = true;
break;
case 0xE9u:
hook_detected = true;
break;
default:
if (*current_pos == 255 && current_pos[1] == 37)
hook_detected = true;
break;
}
if (hook_detected)
{
cb();
}
}
// 示例用法
ac_check_hook((unsigned __int64)&Thread32First, callback);
运行时API导出查找
- TAC有一个内联的API查找函数;它接受模块哈希和API名称哈希,遍历当前加载的模块列表,哈希名称,然后遍历该模块的每个导出函数,并将其与编译时哈希进行比较。
这是反编译后的样子。
以下是他们运行时查找的重现。
void* get_module_base(size_t base, size_t hash)
{
ac_setbase(base);
auto peb = static_cast<PPEB>(NtCurrentPeb());
auto head = &peb->Ldr->InMemoryOrderModuleList;
int mc = 0;
auto entry = head->Flink;
while (entry != head)
{
auto table_entry = CONTAINING_RECORD(entry, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
auto n = static_cast<int>(offsetof(LDR_DATA_TABLE_ENTRY, DllBase));
char buf[255];
size_t count = 0;
wcstombs_s(&count, buf, table_entry->FullDllName.Buffer, table_entry->FullDllName.Length);
// 这只是来自我的哈希工具;+20跳过C:\Windows\System32
auto h = ac_mod64(buf + 20);
if (h == hash)
{
return table_entry->DllBase;
break;
}
entry = entry->Flink;
}
return nullptr;
}
如何找出这些哈希值?
答案非常简单;我获取了游戏进程中所有加载模块的列表,并复制了游戏的哈希函数(注意:DLL名称的哈希方式略有不同),如下所示。
// 用于DLL名称
size_t ac_mod64(const char* str)
{
auto base = ac_getbase();
while (*str)
{
auto v203 = *str++;
auto v39 = v203;
if (v203 >= 0x41u && v39 <= 0x5Au)
v39 += 32;
base = 0x100000001B3i64 * (((v39 & 0xFF00) >> 8) ^ (0x100000001B3i64 * (static_cast<unsigned __int8>(v39) ^
base)));
}
return base;
}
// 用于导出函数名称
size_t ac_fnv64(const char* str)
{
auto base = ac_getbase();
while (*str)
{
auto s = *str++;
auto v12 = s;
if (s >= 65 && v12 <= 90)
v12 += 32;
base = ac_prime * (v12 ^ base);
}
return base;
}
我使用这个函数计算了所有模块名称和导出函数的哈希值,然后创建了一个函数来通过FNV哈希基和API名称的内联哈希查找这些API名称。
以下是我如何缓存和解析所有导出的方法。
void cache_exports()
{
for (auto dll : loadedDlls)
{
HMODULE mod = GetModuleHandleA(dll.c_str());
if (!mod)
{
continue;
}
IMAGE_DOS_HEADER* mz = (PIMAGE_DOS_HEADER)mod;
IMAGE_NT_HEADERS* nt = RVA2PTR(PIMAGE_NT_HEADERS, mz, mz->e_lfanew);
IMAGE_DATA_DIRECTORY* edirp = &nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
IMAGE_DATA_DIRECTORY edir = *edirp;
IMAGE_EXPORT_DIRECTORY* exports = RVA2PTR(PIMAGE_EXPORT_DIRECTORY, mz, edir.VirtualAddress);
DWORD* addrs = RVA2PTR(DWORD*, mz, exports->AddressOfFunctions);
DWORD* names = RVA2PTR(DWORD*, mz, exports->AddressOfNames);
for (unsigned i = 0; i < exports->NumberOfFunctions; i++)
{
char* name = RVA2PTR(char*, mz, names[i]);
void* addr = RVA2PTR(void*, mz, addrs[i]);
MEMORY_BASIC_INFORMATION mbi;
if (ssno::bypass::VirtualQuery((void*)name, &mbi, sizeof(mbi)))
{
if (mbi.AllocationBase == mod)
{
hashes[ac_fnv64(name)] = std::string(name);
}
}
}
}
}
void lookup_hash(size_t base, size_t hash)
{
ac_setbase(base);
hashes.clear();
cache_exports();
if (hashes.find(hash) == hashes.end())
{
printf("Failed to find hash: 0x%p\n", hash);
return;
}
printf("0x%p, 0x%p = %s\n", base, hash, hashes[hash].c_str());
}
- 完成这些后,接下来是手动工作。
- 我手动从反编译中获取了基哈希和函数哈希,然后将它们放入我的程序中。
- 现在我能够准确知道反作弊系统调用了哪些API。
以下是我的工具最终的工作方式。
// (lookup_pebhash是我之前写的get_module_base函数)
lookup_pebhash(0xB8BC6A966753F382u, 0x7380E62B9E1CA6D6); // ntdll
lookup_hash(0x6B9D7FEE4A7D71CEui64, 0xE5FAB4B4E649C7A4ui64); // VirtualProtect
lookup_hash(0x1592DD0A71569429i64, 0xB5902EE75629AA6Cui64); //NtAllocateVirtualMemory
lookup_hash(0x3E4D681B236AE0A0i64, 0x3AB0D0D1450DE52Di64); //GetWindowLongA
lookup_hash(0x77EF6ADABFA1098Fi64, 0x94CA321842195A88ui64); //OpenProcess
lookup_hash(0xA3439F4AFAAB52AEui64, 0xE48550DEAB23A8C9ui64); //K32EnumProcessModules
lookup_hash(0x2004CA9BE823B79Ai64, 0x828CC84F9E74E1A0ui64); //CloseHandle
lookup_hash(0x423E363D6FEF8CEAi64, 0x5B3E9BDB215405F3i64); //K32GetModuleFileNameExW
lookup_hash(0x52D5BB326B1FC6B2i64, 0x1C2D0172D09B7286i64); //GetWindowThreadProcessId
lookup_hash(0x13FA4A203570A0A2i64, 0xB8DA7EDECE20A5DCui64); //GetWindowDisplayAffinity
我想提一下,这些哈希值在不同版本的游戏中不会相同。此外,这并不是击败这种哈希技术的唯一方法;这些函数指针存储在全局变量中,你可以简单地检查它们并将函数的虚拟地址与所有加载的DLL中的导出函数进行匹配。
好了,现在我们已经确定TAC会检测API钩子(它只检查它使用的函数,而不是所有重要的API)。这些检查只是为了监控那些可能会损害或阻止反作弊系统工作的API钩子尝试。
如果有绕过它们钩子检测的钩子方法呢?
调试寄存器
对于实际尝试在游戏中钩子的作弊者来说,Arxan已经覆盖了代码修补;作弊者必须在Arxan存在时使用非代码修补的钩子方法。有几种这样的钩子方法,我在这里列出一些:
- 异常钩子 - 强制触发异常并处理它。
- 异常可以通过多种方式触发。
- 修改全局指针为nullptr或无效的内存地址。
- 修改页面访问权限以触发访问异常(例如:PAGE_NOACCESS或PAGE_GUARD)。
- 调试寄存器 - 告诉CPU在特定指令处中断(抛出STATUS_SINGLE_STEP异常)。
- 这些非常强大;CPU可以在给定指令地址的任何或所有条件下中断。
- 读取
- 写入
- 执行
- 调试寄存器是最容易使用、最流行且最容易检测的!
- 这些非常强大;CPU可以在给定指令地址的任何或所有条件下中断。
由于调试寄存器如此流行和强大,并且完全绕过了Arxan的.text补丁监控,这使得它们成为《使命召唤》游戏的完美钩子技术。
TAC如何检查调试寄存器?
__forceinline void ac_check_debug_registers(HANDLE thread_handle, fn callback)
{
CONTEXT context;
context.ContextFlags = CONTEXT_FULL;
if (!GetThreadContext(thread_handle, &context))
{
return;
}
if (context.Dr0 || context.Dr1 || context.Dr2 || context.Dr3)
{
if (GetProcessIdOfThread(thread_handle) != GetCurrentProcessId())
{
callback("debug registers found, but not in our process");
}
else
{
callback("debug registers found inside current process");
}
// 反作弊系统会跳转到我之前写的退出函数
// 默认会调用ac_terminate_process_clear_registers
// 如果ZwTerminateProcess被钩住,它会跳转到ac_close_game2_crash_zeroxzero
}
}
// 请求的访问权限
__forceinline HANDLE ac_open_thread(int pid)
{
return OpenThread(THREAD_QUERY_INFORMATION | THREAD_GET_CONTEXT, 0, pid);
}
- 由于调试寄存器位于DR0-DR3寄存器中,你不能直接编写一些自定义汇编代码来读取它们,因为这些寄存器是特权寄存器,必须由Windows内核获取或在异常发生时由Windows发送给进程。
; 这将抛出STATUS_PRIVILEGED_INSTRUCTION异常
mov rax, dr0
ret
驱动签名强制
- Windows有一个测试模式,专为驱动程序开发设计。
- 这将允许你绕过Windows对没有有效数字签名的内核模式驱动程序的正常限制。
- 这是为了防止不良行为者在未经适当授权的情况下在你的系统上运行内核模式驱动程序。
- TAC会通过**ntdll!NtQuerySystemInformation**知道你是否在Windows上启用了测试模式。这不会直接封禁你,但会将你的账号标记。
__forceinline bool is_test_signing_on()
{
SYSTEM_CODEINTEGRITY_INFORMATION sys_cii;
sys_cii.Length = sizeof(sys_cii);
NTSTATUS status = NtQuerySystemInformation(103, &sys_cii, static_cast<ULONG>(sizeof(sys_cii)), static_cast<PULONG>(NULL));
if (NT_SUCCESS(status))
{
return !!(sys_cii.CodeIntegrityOptions & /*CODEINTEGRITY_OPTION_TESTSIGN*/ 0x2);
}
return false;
}
__forceinline void ac_check_test_signing(callback cb)
{
if (is_test_signing_on())
{
cb();
}
}
现在我们了解了TAC的一些反静态分析和调试寄存器检测策略。接下来我们将探讨TAC中实现的更高级的检测方法。
TAC如何退出进程?
- TAC使用两种方式退出进程;两者都会清除寄存器,并且这些代码是用内联shellcode编写的。
- 第一种方法在调用NtTerminateProcess时将RCX设置为-1。
- 如果检测到NtTerminateProcess被钩住,TAC不会使用这种方法。
- 如果NtTerminate