Reverse Engineering Call Of Duty Anti-Cheat

Interested in Anti-Cheat analysis? I highly recommend checking out Guided Hacking’s Anti-Cheat section.

I’ve been reversing Black Ops Cold War for a while now, and I’ve finally decided to share my research regarding the user-mode anti-cheat inside the game. It’s not my intention to shame or promote cheating/bypassing of the anti-cheat, so I’ve redacted a few things.

image info

To clear up any confusion, Black Ops Cold War does not have the kernel-mode component of Ricochet that Modern Warfare (2019) and later titles have. I’ll be referring to the anti-cheat as TAC (Treyarch Anti-Cheat) as the game I reversed is a Treyarch game. Also, whenever I provide function pseudocode, it will be the best I can do since the actual decompilation is super cluttered with a lot of junk/resolving code. The biggest difference between the newer games is the kernel-mode driver, while the majority of anti-cheat code is user-mode and very similar to TAC.

Let’s look at how the anti-cheat and the game is protected before we dig too deep.

Arxan

Now we that understand how the game and anti-cheat are protected we can dig deeper. TAC is planted directly into the game executable, uses no kernel components, and will also terminate the process if debug artifacts are found.

How does TAC detect monitoring?

Runtime API Export Lookup

This is what the decomp looks like. image info

Here’s a recreation of their runtime lookup.

  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);

        // this is just from my hash tool; +20 skips past C:\Windows\System32
  		auto h = ac_mod64(buf + 20);
  		if (h == hash)
  		{
            return table_entry->DllBase;
  			break;
  		}

  		entry = entry->Flink;
  	}

    return nullptr;
  }

How can we figure out what these hashes are?

The answer is super simple; I grabbed a list of all the loaded modules in my game process and copied over the game’s hashing function (note: dll names are hashed a little bit differently), which can be seen here.

// this is used for dll names
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;
}

// this is used for exported function names
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;
}

I took that function and calculated the hash of all the module names and exports from the module list that I grabbed, then created a function to look up these API names by using the FNV hash base and the inlined hash of the API name.

Here’s how I managed to cache and resolve all of the exports.

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());
}

Here’s how my tool ended up working.

// (lookup_pebhash is the get_module_base function I wrote about further up)
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

image info

I do want to mention that these hashes aren’t going to be the same in different versions of the game. Also, this isn’t the only way of beating this hashing technique; these function pointers are stored in global variables; you can simply inspect them and match the virtual address of the function to one of the exported functions from all of the DLLs loaded.

Ok, now we have established that TAC detects API hooking (It only checks functions that it uses, not actually checking all important APIs for hooks, just the ones it’s using). These are only here to monitor API hooking attempts that would hurt or prevent the anti-cheat from doing its job.

What if there was a hooking method that bypassed their hooking detections?

Debug Registers

For actual cheaters trying to hook into the game, Arxan has got the code patching covered; cheaters must use non-code patching hooking methods while Arxan is present. There are a couple of these hooking methods, and I’ll list a few here:

Since debug registers are so popular and powerful, and completely bypass Arxan’s .text patch monitoring, this makes them the perfect hooking technique for Call of Duty games.

Here’s how TAC checks for debug registers.

    __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");
          }

          // the anti-cheat would then jump to the quit functions that I wrote about a little bit further down
          // default will call ac_terminate_process_clear_registers
          // if ZwTerminateProcess was hooked it will jump to ac_close_game2_crash_zeroxzero
        }
    }

    // access rights that are requested
    __forceinline HANDLE ac_open_thread(int tid)
    {
      return OpenThread(THREAD_QUERY_INFORMATION | THREAD_GET_CONTEXT, 0, tid);
    }
; This will throw a STATUS_PRIVILEGED_INSTRUCTION exception
mov rax, dr0
ret

Driver Signing Enforcement

__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();
  }
}

Now we understand some of TAC’s anti-static analysis and debug register detection tactics. We’re going to move on to the more advanced detections implemented into TAC.

How does TAC exit the process?

Detecting Cheat Logging

Detecting Visuals

What about External Cheats?

What about tools like Cheat Engine?

Anti-Sig Scanning

Anti-Debugging

Monitoring Network Traffic

Encrypted Custom Syscalls

Let’s take a look at TAC’s custom syscall stub. image info

some_random_text_encrypted_func[0] = ((unsigned __int64)&loc_7FF60E12D0B0 + 4095) & 0xFFFFFFFFFFFFF000uLL;

This was pretty much just copy and paste from IDA Pro, all I did was allocate my own memory here.

	auto v3867 = 12288LL;
	LABEL_1798:
	auto v2168 = __rdtsc() % (v3867 - 3);
	auto v1328 = v2168;
	auto ac_NtReadFile_1 = (char*)GetProcAddress(GetModuleHandleA("ntdll"), "NtReadFile");
	__int64 i67 = 0;
	for (i67 = 0LL; ; ++i67)
	{
		if (v1328 + i67 >= v3867)
			goto LABEL_1798;
		if (ac_NtReadFile_1[i67 + 1 + v1328] == 5
			&& (unsigned __int8)ac_NtReadFile_1[i67 + 2 + v1328] == 195
			&& ac_NtReadFile_1[i67 + v1328] == 15)
		{
			break;
		}
	}

	auto nt_read_file_syscall_instruction = &ac_NtReadFile_1[i67 + v1328];
	volatile __int64 syscall_stub_memory = (__int64)VirtualAlloc(nullptr, 0x4096, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

	__int64 syscall_index = 0; // this is going to be the syscall index; it's 0 here just while I'm explaining
	auto offset_that_doesnt_matter = 0x50; // it's just here to add to the confusion; this can be any number above 4

	*(_QWORD*)(syscall_stub_memory + offset_that_doesnt_matter + 28LL) = (__int64)nt_read_file_syscall_instruction;
	*(_QWORD*)(syscall_stub_memory + offset_that_doesnt_matter + 20LL) = 0x63B4B73DD1E509A9LL;
	*(_QWORD*)(syscall_stub_memory + offset_that_doesnt_matter + 20LL) ^= 0x7FA6B73DD1E72C56uLL;
	*(_DWORD*)(syscall_stub_memory + offset_that_doesnt_matter + 12LL) = syscall_index;
	*(_DWORD*)(syscall_stub_memory + offset_that_doesnt_matter + 8LL) = -997864955;
	*(_DWORD*)(syscall_stub_memory + offset_that_doesnt_matter + 8LL) ^= 0x7CEB6A07u;
	*(_DWORD*)(syscall_stub_memory + offset_that_doesnt_matter) = -1006268688;
	*(_DWORD*)(syscall_stub_memory + offset_that_doesnt_matter) ^= 0x62ADC0BFu;
	*(_DWORD*)(syscall_stub_memory + offset_that_doesnt_matter + 4LL) = -1637542171;
	*(_DWORD*)(syscall_stub_memory + offset_that_doesnt_matter + 4LL) ^= 0x75B49DA9u;
	*(_DWORD*)(syscall_stub_memory + offset_that_doesnt_matter + 16LL) = 109211239;
	*(_DWORD*)(syscall_stub_memory + offset_that_doesnt_matter + 16LL) ^= 0xBBCA6C8C;


	auto syscall_stub_ptr = (__int64(__fastcall*)(_QWORD, _QWORD, _QWORD, _QWORD))(syscall_stub_memory + offset_that_doesnt_matter + 4LL);
    printf("memory allocated: %p\n", syscall_stub_ptr);
    getchar();

Inspecting this memory address reveals the unencrypted shellcode and we can see the standard syscall stub here.

Starting with the “mov r10, rcx” instruction.

image info

Following that jmp after the mov, 0x2C is the NtTerminateProcess syscall index for my Windows version, and we can see that being moved into eax.

image info

Following the jump after mov eax, this is where the address of the syscall instruction comes in; it’s just a jump to it.

Syscall instruction. image info image info

We can take a look at where this syscall instruction is located, just to verify that it’s a bit random. image info

And just to double-check, if we run the code again, our syscall instruction location will change! image info

Just for bonus points, I’ve recreated their syscalling method.

__forceinline int get_syscall_index(unsigned __int64 address)
{
	return *(int*)&reinterpret_cast<char*>(address)[4];
}

__forceinline __int64 get_syscall_instruction_address(unsigned __int64 func)
{
	const auto distance = 12288LL;
LABEL_1798:
	const auto starting_distance = __rdtsc() % (distance - 3);
	auto ntdll_exported_func = reinterpret_cast<char*>(func);

	__int64 syscall_instruction_spot = 0;
	for (syscall_instruction_spot = 0LL; ; ++syscall_instruction_spot)
	{
		if (starting_distance + syscall_instruction_spot >= distance)
			goto LABEL_1798;
		if (ntdll_exported_func[syscall_instruction_spot + 1 + starting_distance] == 5
			&& (unsigned __int8)ntdll_exported_func[syscall_instruction_spot + 2 + starting_distance] == 195
			&& ntdll_exported_func[syscall_instruction_spot + starting_distance] == 15)
		{
			break;
		}
	}

	return reinterpret_cast<unsigned __int64>(&ntdll_exported_func[syscall_instruction_spot + starting_distance]);
}

__forceinline void* generate_syscall_stub(unsigned __int64 syscall_instruction, const int syscall_index, void** base, int* size)
{
	if (base == nullptr || size == nullptr)
	{
		return nullptr;
	}

	*size = 0x4096;

	auto offset = rand() % (*size - 0x40);

    // using virtual allocated memory just for the example, game has a .text blob allocated for this
	volatile __int64 syscall_stub_memory = reinterpret_cast<__int64>(VirtualAlloc(nullptr, *size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE));
	*(_QWORD*)(syscall_stub_memory + offset + 28LL) = (__int64)syscall_instruction;
	*(_QWORD*)(syscall_stub_memory + offset + 20LL) = 0x63B4B73DD1E509A9LL;
	*(_QWORD*)(syscall_stub_memory + offset + 20LL) ^= 0x7FA6B73DD1E72C56uLL;
	*(_DWORD*)(syscall_stub_memory + offset + 12LL) = syscall_index;
	*(_DWORD*)(syscall_stub_memory + offset + 8LL) = -997864955;
	*(_DWORD*)(syscall_stub_memory + offset + 8LL) ^= 0x7CEB6A07u;
	*(_DWORD*)(syscall_stub_memory + offset) = -1006268688;
	*(_DWORD*)(syscall_stub_memory + offset) ^= 0x62ADC0BFu;
	*(_DWORD*)(syscall_stub_memory + offset + 4LL) = -1637542171;
	*(_DWORD*)(syscall_stub_memory + offset + 4LL) ^= 0x75B49DA9u;
	*(_DWORD*)(syscall_stub_memory + offset + 16LL) = 109211239;
	*(_DWORD*)(syscall_stub_memory + offset + 16LL) ^= 0xBBCA6C8C;

	*base = reinterpret_cast<void*>(syscall_stub_memory);
	return reinterpret_cast<void*>(syscall_stub_memory + offset + 4LL);
}

__forceinline void free_syscall_stub(void* base, int size)
{
	memset(base, 0, size);
	VirtualFree(base, 0, MEM_RELEASE);
}

template<typename... Params>
__forceinline NTSTATUS spoof_syscall(unsigned __int64 exported_ntdll_function, unsigned __int64 function_to_call, Params... params)
{
	void* base_address_of_stub = nullptr;
	int stub_size = 0;

	const auto nt_syscall_instruction = get_syscall_instruction_address(exported_ntdll_function);
	const auto syscall_index = get_syscall_index(function_to_call);
	void* stub = generate_syscall_stub(nt_syscall_instruction, syscall_index, &base_address_of_stub, &stub_size);

	NTSTATUS result = reinterpret_cast<NTSTATUS(__fastcall*)(Params...)>(stub)(params...);

	free_syscall_stub(base_address_of_stub, stub_size);
	return result;
}

__forceinline void terminate_process()
{
	const auto syassasd = reinterpret_cast<unsigned __int64>(GetProcAddress(LoadLibraryA("ntdll"), "NtTerminateProcess"));
	const auto spoof_start = reinterpret_cast<unsigned __int64>(GetProcAddress(LoadLibraryA("ntdll"), "NtOpenFile"));
	spoof_syscall(spoof_start, syassasd, static_cast<HANDLE>(-1), 1337);
}

int main(int argc, const char** argv)
{
	terminate_process();
}

Let’s test this just to make sure.

image info

Detecting Anti-Debugger-Hiding Attempts

Create Remote Thread Blocking

Dumping Exception Handlers

For those interested in how I dumped the exception handlers, I’ve provided the code. You’ll need to update these offsets if you want to use this.

void dump_exception_handlers()
{
  // 75 ? 4C 8D 9C 24 ? ? ? ? 48 8B C3 : mov xxx, rsi
  __int64 exception_filter = (__int64)GetModuleHandleA("kernelbase.dll") + 0x28CC60;
  auto rtl_decode_pointer = reinterpret_cast<__int64(__fastcall*)(__int64)>(get_address("ntdll.dll", "RtlDecodePointer"));

  // F0 0F AB 48 : lea rcx
  PLDRP_VECTOR_HANDLER_LIST vector_list = (PLDRP_VECTOR_HANDLER_LIST)((__int64)GetModuleHandleA("ntdll.dll") + 0x17F3E8);
  LIST_ENTRY* list_head = &vector_list->LdrpVehList;

  // this will be the function passed into SetUnhandledExceptionFilter
  log("UnhandledExceptionFilter: 0x%p\n", rtl_decode_pointer(*(__int64*)exception_filter));

  // dump out the vectored handler list
  for (LIST_ENTRY* list_entry = list_head->Flink; list_entry != list_head; list_entry = list_entry->Flink)
  {
  	PVECTOR_HANDLER_ENTRY pEntry = CONTAINING_RECORD(list_entry, VECTOR_HANDLER_ENTRY, ListEntry);
  	__int64 pExceptionHandler = rtl_decode_pointer((__int64)pEntry->EncodedHandler);
  	TCHAR modname[MAX_PATH];
  	GetModuleBaseNameW(GetCurrentProcess(), GetModuleHandle(NULL), modname, MAX_PATH);
  	log("VEH: 0x%p (%ws) [0x%p]\n", pExceptionHandler, modname, pExceptionHandler - (__int64)GetModuleHandleW(modname));
  }

  // dump out the continued handler list
  list_head = &vector_list->LdrpVchList;
  for (LIST_ENTRY* list_entry = list_head->Flink; list_entry != list_head; list_entry = list_entry->Flink)
  {
  	PVECTOR_HANDLER_ENTRY pEntry = CONTAINING_RECORD(list_entry, VECTOR_HANDLER_ENTRY, ListEntry);
  	__int64 pExceptionHandler = rtl_decode_pointer((__int64)pEntry->EncodedHandler);
  	TCHAR modname[MAX_PATH];
  	GetModuleBaseNameW(GetCurrentProcess(), GetModuleHandle(NULL), modname, MAX_PATH);
  	log("VCH: 0x%p (%ws) [0x%p]\n", pExceptionHandler, modname, pExceptionHandler - (__int64)GetModuleHandleW(modname));
  }
}

Mystery Tech?

I’m not sure what this is, but it looks like something that would flag virtual machines or custom versions of Windows.

void ac_check_allocation_grad(fn callback)
{
  SYSTEM_BASIC_INFORMATION sbi;
  NtQuerySystemInformation(0, &sbi, sizeof(sbi), nullptr);

  if (sbi.AllocationGranularity != 0x10000)
  {
    callback();
  }

}

Since TAC is so reliant on the linked module list, they have a check that prevents someone from setting it to an empty list. Setting this to an empty list will probably break the process anyway.

void ac_detect_invalidated_module_list(fn callback)
{
  const auto memory_module_list = &NtCurrentPeb()->Ldr->InMemoryOrderModuleList;
  if (memory_module_list->Flink == memory_module_list)
  {
    callback();
  }
}

The End

TAC is a pretty cool user-mode anti-cheat, with features such as runtime API lookups, detecting poorly made hooks by passing clever invalid parameters, external overlay detection, internal DirectX hook detection, checking APIs that it uses for hooks, checking for debuggers and debugging artifacts, AllocConsole detection, CreateRemoteThread detection, and the coolest of all, spoofed and encrypted syscall stubs. Arxan really helps out TAC; it has powerful obfuscation, anti-static analysis methods, and a couple of features that break IDA Pro, all while monitoring the executable for .text modifications. It even has its own anti-debug techniques built-in. Similar code from TAC is being used in modern Call of Duty games. Overall, this was a huge learning experience, and a great challenge, and pretty unreal to see all the things that caught me! I hope you found my research interesting. I’m still not 100% done reversing the anti-cheat, so you can expect to see new information posted here sometime in the future! :)


References