[博客翻译]逆向工程呼唤反作弊


原文地址:https://ssno.cc/posts/reversing-tac-1-4-2025/


逆向工程《使命召唤》反作弊系统

我已经对《使命召唤:黑色行动冷战》进行了一段时间的逆向工程,现在决定分享一些关于游戏内用户模式反作弊系统的研究成果。本文的目的并非鼓励或推广绕过反作弊系统,因此我会对一些内容进行删减。

游戏截图

首先澄清一点,《黑色行动冷战》并没有像《现代战争》(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可以在给定指令地址的任何或所有条件下中断。
      • 读取
      • 写入
      • 执行
    • 调试寄存器是最容易使用、最流行且最容易检测的!

由于调试寄存器如此流行和强大,并且完全绕过了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不会使用这种方法。
    • 如果NtTerminateProcess被钩住,它会跳转到第二种方法,即跳转到0x0。
xor     rax, rax 
xor     rbx, rbx 
xor     rcx, rcx 
dec     rcx 
xor     rdx, rdx 
xor     rsi, rsi 
xor     rdi, rdi 
xor     r8, r8 
xor     r9, r9 
xor     r10, r10 
xor     r11, r11 
xor     r12, r12 
xor     r13, r13 
xor     r14, r14 
xor     r15, r15 
mov     rsp, 0x0F8 
jmp     qword ptr [0x1B607DC7FF0] 
; 这不是自定义的系统调用设置;这是跳转到ntdll!NtTerminateProcess。
spot_1B607DC7FF0:
mov     r10, rcx 
mov     eax, 0x2C 
test    byte ptr [0x7FFE0308], 1 
jne     NtTerminateProcess + 0x15 (0x07FFA7A3CDA75) 
syscall 
ret 

以下是第二种方法;我们可以看到相同的寄存器清除操作,并且有一个跳转。这个跳转会跳转到0x0,这将导致进程崩溃。

xor     rax, rax 
xor     rbx, rbx 
xor     rcx, rcx 
xor     rdx, rdx 
xor     rsi, rsi 
xor     rdi, rdi 
xor     r8, r8 
xor     r9, r9 
xor     r10, r10 
xor     r11, r11 
xor     r12, r12 
xor     r13, r13 
xor     r14, r14 
xor     r15, r15 
xor     rsp, rsp 
xor     rbp, rbp 
jmp     qword ptr [0x27E45550036] 
; 0x27E45550036的值 = 0x000000000000

由于所有重要的寄存器都被清除,这些方法很难恢复。以下是生成和运行这些shellcode的代码。

// 这些代码经过重构以提高可读性
void ac_terminate_process_clear_registers()
{
	const auto memory = reinterpret_cast<unsigned __int64>(VirtualAlloc(
   nullptr, 
   0x8000uLL, 
   MEM_COMMIT | MEM_RESERVE, 
   PAGE_EXECUTE_READWRITE
  ));
	const auto proc_addr = reinterpret_cast<unsigned __int64>(GetProcAddress(
   LoadLibraryA("ntdll.dll"), 
   "ZwTerminateProcess"
  ));
	unsigned char terminate_process_shellcode[] =
	{
		0x48, 0x31, 0xC0, // xor rax, rax
		0x48, 0x31, 0xDB, // xor rbx, rbx
		0x48, 0x31, 0xC9, // xor rcx, rcx
		0x48, 0xFF, 0xC9, // dec rcx
		0x48, 0x31, 0xD2, // xor rdx, rdx
		0x48, 0x31, 0xF6, // xor rsi, rsi
		0x48, 0x31, 0xFF, // xor rdi, rdi
		0x4D, 0x31, 0xC0, // xor r8, r8
		0x4D, 0x31, 0xC9, // xor r9, r9
		0x4D, 0x31, 0xD2, // xor r10, r10
		0x4D, 0x31, 0xDB, // xor r11, r11
		0x4D, 0x31, 0xE4, // xor r12, r12
		0x4D, 0x31, 0xED, // xor r13, r13
		0x4D, 0x31, 0xF6, // xor r14, r14
		0x4D, 0x31, 0xFF, // xor r15, r15
		0x48, 0xC7, 0xC4, 0xF8, 0x00, 0x00, 0x00, // mov rsp, 0x0F8
		0xFF, 0x25, 0x00, 0x00, 0x00, 0x00 // jmp QWORD PTR [rip + 0x0]
	};
	const auto zw_terminate_process_spot = 0x320;
	// 将ZwTerminateProcess的地址写入某个地方
	*reinterpret_cast<__int64*>(memory + zw_terminate_process_spot) = proc_addr;
	// 计算写入ZwTerminateProcess地址的内存偏移量(需要是RIP的RVA)
	const auto rva_addy = zw_terminate_process_spot - sizeof(terminate_process_shellcode);
	*reinterpret_cast<DWORD*>(&terminate_process_shellcode[sizeof (terminate_process_shellcode) - 4]) = rva_addy;
	memcpy(reinterpret_cast<void*>(memory), terminate_process_shellcode, sizeof (terminate_process_shellcode));
	reinterpret_cast<void(*)()>(memory)();
}
void ac_close_game2_crash_zeroxzero()
{
 	const auto memory = reinterpret_cast<__int64>(VirtualAlloc(
   nullptr, 
   0x40uLL, 
   MEM_COMMIT | MEM_RESERVE, 
   PAGE_EXECUTE_READWRITE
  ));
  memset(reinterpret_cast<void*>(memory), 0, 0x40);
  unsigned char zero_zero_shellcode[] = 
  {
  	0x48, 0x31, 0xC0, // xor rax, rax
  	0x48, 0x31, 0xDB, // xor rbx, rbx
  	0x48, 0x31, 0xC9, // xor rcx, rcx
  	0x48, 0x31, 0xD2, // xor rdx, rdx
  	0x48, 0x31, 0xF6, // xor rsi, rsi
  	0x48, 0x31, 0xFF, // xor rdi, rdi
  	0x4D, 0x31, 0xC0, // xor r8, r8
  	0x4D, 0x31, 0xC9, // xor r9, r9
  	0x4D, 0x31, 0xD2, // xor r10, r10
  	0x4D, 0x31, 0xDB, // xor r11, r11
  	0x4D, 0x31, 0xE4, // xor r12, r12
  	0x4D, 0x31, 0xED, // xor r13, r13
  	0x4D, 0x31, 0xF6, // xor r14, r14
  	0x4D, 0x31, 0xFF, // xor r15, r15
  	0x48, 0x31, 0xE4, // xor rsp, rsp
  	0x48, 0x31, 0xED, // xor rbp, rbp
  	0xFF, 0x25, 0x00, 0x00, 0x00, 0x00 // jmp qword ptr[rip + 0]
  };
  // 这个偏移量将指向已经为0x00000000的内存
  *reinterpret_cast<DWORD*>(&zero_zero_shellcode[sizeof(zero_zero_shellcode) - 4]) = 4;
  memcpy(reinterpret_cast<void*>(memory), zero_zero_shellcode, sizeof (zero_zero_shellcode));
  reinterpret_cast<void(*)()>(memory)();
} 

检测作弊日志

  • 通常,内部作弊者会使用AllocConsole分配一个控制台来打印他们的调试日志,甚至使用打印语句在其中制作他们的菜单。
    • PEB(进程环境块)包含有关控制台分配的信息。
      • PEB包含大量有关进程的有用信息。如果你想了解更多,请点击这里;这个结构的大部分是未记录的,但这会带你到MSDN页面。
    • 游戏知道不应该分配控制台窗口;检查这一点非常简单,并且会捕获一些使用这种方法记录日志的人。以下是实现。
__forceinline void ac_detect_allocated_console(fn callback)
{
 if (GetConsoleWindow() != 0 || NtCurrentPeb() ->ProcessParameters->ConsoleHandle != 0)
 {
  callback();
 }
}

检测视觉效果

  • 作弊者需要在屏幕上绘制内容,如果他们想要一个可用的ESP或甚至一个菜单/用户界面
    • 他们如何做到这一点?
      • 有几种在屏幕上绘制内容的方法;内部作弊最常见的方法是钩住游戏使用的图形API,并在其中绘制他们的信息。
      • 现代《使命召唤》游戏使用DirectX 12
      • 当想要在DirectX上绘制内容时,常见的钩子函数是IDXGISwapChain::Present
      • Present函数是将渲染的游戏图像呈现给用户的函数。想法是在图像呈现给用户之前添加自定义数据,更新图像然后呈现给用户。
    • 如何检测这一点?
      • 首先,我们需要了解这些函数是如何被钩住的。
        • DirectX函数位于你计算机上的dxgi DLL中。
        • DirectX接口持有指向dxgi中函数的vtable,这些vtable索引在Windows版本中是相同的。
        • IDXGISwapChain vtable持有present函数。
        • 这可以直接钩住,或者进行指针交换。
      • 检测这些钩子。
        • 可以扫描DXGI present函数,但TAC目前不这样做。
        • TAC检查vtable中的present指针。
        • DirectX 12引入了命令队列;每当游戏绘制某些内容时,它都来自命令队列,即绘制命令列表。这也是作弊者需要的,如果他们想要绘制内容。
          • 作弊者如何获取这个命令队列?
            • 获取游戏命令队列的最常见方法是钩住命令队列接口中的另一个函数,ID3D12CommandQueue::ExecuteCommandLists
            • 由于这是一个接口,方法会有一个**this**指针作为第一个参数传递;在这种情况下,该指针是命令队列。
    • 有趣的信息:
      • 许多工具如OBS Studio/Streamlabs OBS/Discord的游戏覆盖/Steam的游戏覆盖实际上都这样做,而Steam和Discord在这里绘制内容,像OBS studio这样的工具只是在这里捕获渲染的图像(捕获帧)并将其保存到你的录制中。这只有在你在录制软件中使用游戏捕获时才会发生。
      • 作弊者可以忽略游戏,直接钩住Discord/Steam并在那里绘制内容,如果他们想要的话。

外部作弊工具呢?

  • 外部作弊工具很可能会创建一个覆盖游戏窗口宽度和高度的重叠窗口。有几种方法可以检测到这一点;检测方法严重依赖Windows API,并且需要更多的努力来实现。
  • TAC会遍历所有窗口,使用GetWindowLongA检查它们的窗口样式是否为WS_EX_LAYERED;一旦找到,它会使用GetWindowRect将该窗口的矩形与游戏矩形进行比较。
  • 完成所有这些后,如果窗口覆盖了游戏的矩形并且它是一个分层窗口,该窗口的句柄将被缓存,稍后将用于许多字符串检查。这会存储大量信息并将其上传到他们的服务器。

以下是如何工作的。

GetWindowRect(hwnd, &output_rect);
if (output_rect.right >= game_rect_7FF61BBA2F50.left &&
  output_rect.left <= game_rect_7FF61BBA2F50.right &&
  output_rect.bottom >= game_rect_7FF61BBA2F50.top &&
  output_rect.top <= game_rect_7FF61BBA2F50.bottom)
{
 min_value = get_min_value(output_rect.left, game_rect_7FF61BBA2F50.left);
 greater_value = get_greater_value(output_rect.right, game_rect_7FF61BBA2F50.right);
 v193 = get_min_value(output_rect.top, game_rect_7FF61BBA2F50.top);
 v195 = get_greater_value(output_rect.bottom, game_rect_7FF61BBA2F50.bottom);
 v76 = (float)((v193 - v195) * (greater_value - min_value))
   / (float)((game_rect_7FF61BBA2F50.top - game_rect_7FF61BBA2F50.bottom)
       * (game_rect_7FF61BBA2F50.right - game_rect_7FF61BBA2F50.left));
 if (v76 >= 0.5 && cached_window_count < 8)
  cached_windows[cached_window_count++] = hwnd;
}
RECT game_rect_7FF61BBA2F50;
game_rect_7FF61BBA2F50 RECT <0, 0, 780h, 438h>;
  • 我们可以看到0x780和0x438,代表1920x1080,这是我的屏幕分辨率。好了,我们知道窗口被缓存了。现在呢?
  • 缓存的列表在另一个函数中处理,该函数检查窗口文本及其类名。
  • TAC使用[GetWindowText
阅读全文(20积分)