Исследование античита TAC в Black Ops Cold War: полный технический разбор

Исследование античита TAC в Black Ops Cold War: полный технический разбор

Углубленное исследование механизмов работы защиты от читеров TAC, особенностей реализации функций и методов обнаружения внешнего вмешательства в Black Ops Cold War.

image

Исследователь ssno уже некоторое время занимается реверс-инжинирингом Black Ops Cold War и решил поделиться своими исследованиями, касающимися пользовательского античита в игре. Данный материал не несет в себе цели очернять разработчиков и пропагандировать читы или методы обхода античита, поэтому некоторые детали были отредактированы.

Чтобы прояснить возможные недоразумения: в Black Ops Cold War нет драйвера в пространстве ядра (kernel-mode) Ricochet, который используется в Modern Warfare (2019) и более поздних играх серии. В представленном материале этот античит будет называться TAC (Treyarch Anti-Cheat), поскольку игра, подвергавшаяся реверс-инжинирингу, является проектом Treyarch. Также при приведении псевдокода функций стоит учитывать, что фактическая декомпиляция сильно усложнена большим количеством «мусорного» и «резолвящего» кода. Основное отличие более новых игр — наличие драйвера в пространстве ядра, при том что большая часть кода античита по-прежнему находится в пространстве пользователя и во многом схожа с TAC.

Ниже рассмотрены методы защиты самой игры и античита, прежде чем перейти к детальному описанию.


Arxan

Arxan — это инструмент обфускации/защиты, применяемый во многих играх серии Call of Duty (чаще всего во всех проектах, вышедших после Black Ops 3). В его состав входят различные механизмы, существенно осложняющие жизнь разработчикам читов и специалистам по реверс-инжинирингу.

Runtime Executable Decryption (Расшифровка исполняемого файла во время выполнения)

Исполняемый файл игры упакован и зашифрован; Arxan внедряет код в процессе запуска, чтобы распаковать и расшифровать основной .exe игры.

Executable Checksums (Контрольные суммы исполняемого файла)

Arxan непрерывно отслеживает целостность .exe-файла, проверяя, не вносятся ли в него какие-либо изменения. Если Arxan обнаруживает отладчик или несоответствие контрольных сумм, процесс игры завершается.

Jmp Obfuscation (Затемнение кода с помощью инструкций jmp)

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

; etc

Анализировать такие функции статически очень непросто, особенно если это большие фрагменты кода с множеством переходов.

Entrypoint Obfuscation (Запутывание точки входа)

Точка входа в игру также защищена Arxan. Сначала выполняется защищённый код, который распаковывает и запускает настоящую точку входа, а затем, при необходимости, дополнительно добавляются те же «jmp-заглушки», усложняя понимание происходящего.

Pointer Encryption (Шифрование указателей)

Длительное время считалось, что это часть Arxan, однако согласно недавним данным, механизм шифрования указателей — это наработки Treyarch, которыми они могли делиться с Infinity Ward (или наоборот). Ключевые указатели — например, текущая глобальная структура игры (game glob), массив сущностей (entity array), указатели объектов и т. д. — постоянно зашифрованы и расшифровываются непосредственно перед использованием.

  • Существует 16 вариаций одного и того же метода шифрования; конкретный метод выбирается на основе адреса PEB.
  • Такое решение действительно усложняет реверс-инжиниринг, заставляя извлекать расшифрованный указатель из стека.
  • Это также мешает сканированию Cheat Engine по указателям, ведь в глобальном пространстве хранится лишь зашифрованное значение.

Получить расшифрованные указатели можно несколькими способами (не единственно возможными):

  1. Использовать специальный инструмент, чтобы трассировать инструкции расшифровки.
  2. Поставить хуки (hooks) там, где игра уже расшифровывает указатели для своих целей.
__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 полностью размещён в пользовательском пространстве, не имеет драйверов в ядре и, как и Arxan, завершит процесс при обнаружении следов отладки.


Как TAC обнаруживает мониторинг?

API Hook Detection (Обнаружение хуков API)

TAC ориентирован на Windows и, следовательно, использует специфичные для неё API-функции. При этом античит проверяет лишь небольшое количество паттернов (всего 7), «зашитых» в код. Похоже, эти паттерны были взяты напрямую из ранее обнаруженных «подпрыгивающих» вставок читов.

Важно: пример кода ниже показывает, какие именно функции TAC использует, какие проверяет на хуки и какие динамически резолвит (вычисляет) во время выполнения. При этом основная часть TAC сильно «инлайнируется» (интегрируется в основной поток кода).

// Фрагменты проверяемого кода (stub)

; First stub
push   rax
movabs rax,0x0
xchg   QWORD PTR [rsp],rax
ret

; Second Stub
push   rbx
movabs rbx,0x0
xchg   QWORD PTR [rsp],rbx
ret

; Third Stub
push   rcx
movabs rcx,0x0
xchg   QWORD PTR [rsp],rcx
ret

; Fourth Stub
push   rdx
movabs rdx,0x0
xchg   QWORD PTR [rsp],rdx
ret

; Fifth Stub
push   0x0
ret

; Sixth Stub (any call, 0xE8, 0x0, 0x0, 0x0, 0x0)
call 0x00000

; Seventh Stub (any jmp [rip+x], 0xFF, 0x25, 0x00, 0x00, 0x00, 0x00)
jmp QWORD PTR [rip+0]
__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();
  }
}

// example usage
ac_check_hook((unsigned __int64)&Thread32First, callback);

Runtime API Export Lookup (Поиск экспорта API во время выполнения)

TAC содержит встроенную функцию поиска API, которая перебирает список модулей, загруженных в текущем процессе, хеширует их имена и экспортированные функции, а затем сравнивает эти хеши с зашитыми в коде значениями. Вот как выглядит перебор:

Ниже пример воссоздания поиска во время выполнения:

void* get_module_base(size_t base, size_t hash)
{
  	ac_setbase(base);

  	auto peb = static_cast(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(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;
}

Поиск значений хеша

Чтобы вычислить, какой API скрывается за конкретными хешами, достаточно перебрать все модули и их экспорт, применяя такую же FNV-хеш-функцию. Ниже упрощённые версии функций для хеширования.

// для 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(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;
}

Затем можно создать функцию, которая вычисляет хеши для всех экспортов и сопоставляет их с хешами из античита:

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

Затем вручную собираются все хеш-значения из кода TAC и сверяются таким образом. Пример вызова:

// Пример
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

Таким образом выясняется, какие именно функции использует античит. Важно отметить, что в разных версиях игры хеши могут отличаться. Также можно просто анализировать глобальные переменные, в которых хранятся указатели на эти функции, и сопоставлять с экспортами всех загруженных модулей.

Итак, установлено, что TAC проверяет хуки только на тех функциях API, которые ему нужны для внутренней работы. Это минимальный набор проверок, направленных на защиту от вмешательства в код античита.

Что будет, если «хукать» функции, не попадающие под эти проверки?

Debug Registers (Регистры отладки)

В условиях Arxan нельзя патчить код (например, вставлять JMP-инструкции), поэтому для разработки читов приходится искать другие методы. Один из наиболее распространённых — использование механизмов исключений или debug-регистров CPU (DR0–DR3), которые позволяют перехватывать выполнение кода при доступе к заданному адресу.

Debug-регистры — мощный и популярный способ, но они легко детектируются. 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
      // если NtTerminateProcess был «заhook’ен», то вызывается ac_close_game2_crash_zeroxzero
    }
}

__forceinline HANDLE ac_open_thread(int pid)
{
  return OpenThread(THREAD_QUERY_INFORMATION | THREAD_GET_CONTEXT, 0, pid);
}

Поскольку DR0–DR3 — привилегированные регистры, к ним невозможно обратиться напрямую без участия ядра. Поэтому TAC через GetThreadContext проверяет, не установлены ли какие-либо аппаратные точки останова.

Driver Signing Enforcement (Проверка режима подписи драйверов)

Windows блокирует загрузку неподписанных драйверов в обычном режиме. Однако есть «Test Mode», позволяющий разработчикам тестировать драйверы. TAC может определить, включён ли этот режим через NtQuerySystemInformation. Сам факт включённого «Test Mode» напрямую не даёт бана, но помечает аккаунт как «подозрительный».

__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(sizeof(sys_cii)), static_cast(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 завершает процесс?

Античит использует два способа завершения игры, предварительно обнуляя все регистры. Оба они оформлены как «встроенный шелл-код» (inline shelli).

  1. Первый способ: устанавливает RCX в -1 и вызывает NtTerminateProcess. TAC не использует этот способ, если «NtTerminateProcess» заhook’ен.
  2. Второй способ: прыгает на адрес 0x0, что вызывает краш процесса.

Первый метод (Shelli с вызовом NtTerminateProcess):

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

Второй метод (Shelli, выполняющий прыжок на нулевой адрес и тем самым вызывающий краш):

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

Подобный подход делает возврат в игру практически невозможным, поскольку все ключевые регистры уже сброшены в ноль.

Пример функций, генерирующих подобный шелл-код:

void ac_terminate_process_clear_registers()
{
	const auto memory = reinterpret_cast(VirtualAlloc(
      nullptr, 
      0x8000uLL, 
      MEM_COMMIT | MEM_RESERVE, 
      PAGE_EXECUTE_READWRITE
    ));

	const auto proc_addr = reinterpret_cast(GetProcAddress(
      LoadLibraryA("ntdll.dll"), 
      "ZwTerminateProcess"
    ));

	unsigned char terminate_process_shelli[] =
	{
		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;
	*reinterpret_cast<__int64*>(memory + zw_terminate_process_spot) = proc_addr;
	const auto rva_addy = zw_terminate_process_spot - sizeof(terminate_process_shelli);

	*reinterpret_cast<DWORD*>(&terminate_process_shelli[sizeof (terminate_process_shelli) - 4]) = rva_addy;
	memcpy(reinterpret_cast<void*>(memory), terminate_process_shelli, sizeof (terminate_process_shelli));
	reinterpret_cast<void(*)()>(memory)();
}

void ac_close_game2_crash_zeroxzero()
{
  	const auto memory = reinterpret_cast(VirtualAlloc(
      nullptr, 
      0x40uLL, 
      MEM_COMMIT | MEM_RESERVE, 
      PAGE_EXECUTE_READWRITE
    ));

    memset(reinterpret_cast<void*>(memory), 0, 0x40);

    unsigned char zero_zero_shelli[] = 
    {
    	0x48, 0x31, 0xC0,
    	0x48, 0x31, 0xDB,
    	0x48, 0x31, 0xC9,
    	0x48, 0x31, 0xD2,
    	0x48, 0x31, 0xF6,
    	0x48, 0x31, 0xFF,
    	0x4D, 0x31, 0xC0,
    	0x4D, 0x31, 0xC9,
    	0x4D, 0x31, 0xD2,
    	0x4D, 0x31, 0xDB,
    	0x4D, 0x31, 0xE4,
    	0x4D, 0x31, 0xED,
    	0x4D, 0x31, 0xF6,
    	0x4D, 0x31, 0xFF,
    	0x48, 0x31, 0xE4,
    	0x48, 0x31, 0xED,
    	0xFF, 0x25, 0x00, 0x00, 0x00, 0x00
    };

    *reinterpret_cast<DWORD*>(&zero_zero_shelli[sizeof(zero_zero_shelli) - 4]) = 4;

    memcpy(reinterpret_cast<void*>(memory), zero_zero_shelli, sizeof (zero_zero_shelli));
    reinterpret_cast<void(*)()>(memory)();
}

Обнаружение ведения логов внутри игры

Иногда читы ведут логи через AllocConsole, используя консольное окно для вывода отладочной информации или даже для отображения меню. В блоке PEB->ProcessParameters есть дескриптор консоли ConsoleHandle. Игра знает, что в норме консоль быть не должна, поэтому проверка становится тривиальной:

__forceinline void ac_detect_allocated_console(fn callback)
{
  if (GetConsoleWindow() != 0 || NtCurrentPeb()->ProcessParameters->ConsoleHandle != 0)
  {
    callback();
  }
}

Обнаружение визуальных наложений (ESP, меню и т. д.)

Чтобы отрисовать ESP или меню, внутренние читы часто перехватывают (hook) графический API, например, DirectX 12 (используемый современной линейкой Call of Duty). Распространённая точка перехвата — это функция IDXGISwapChain::Present. Или же, в случае DirectX 12, могут перехватываться методы ID3D12CommandQueue::ExecuteCommandLists.

Как TAC это детектирует?

В данном случае TAC не делает сигнатурного сканирования по самой функции Present, но проверяет указатель Present в vtable (виртуальной таблице) объекта IDXGISwapChain. То же может касаться и ID3D12CommandQueue.

Внешние читы

Внешние читы обычно создают наложение (overlay) в виде полноэкранного прозрачного окна, которое рисует поверх игры. TAC, используя функции GetWindowLongA и GetWindowRect, проверяет все окна с флагом WS_EX_LAYERED и сравнивает координаты таких окон с координатами игрового окна. Если окно перекрывает не менее 50% площади окна игры, оно кэшируется для дальнейшего анализа.

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

Затем TAC считывает названия (title) и классы (className) этих окон с помощью GetWindowTextW и GetClassNameA, проверяет их видимость, дополнительный стиль (SetWindowDisplayAffinity) и загруженные модули. В итоге формируется список, который отправляется на сервер Treyarch.

Код обработки кэшированных окон может выглядеть так:

void ac_cached_window(HWND hwnd)
{
	if (hwnd == game_hwnd)
	{
		return;
	}
	const auto is_visible = (GetWindowLongA(hwnd, GWL_STYLE) & WS_VISIBLE) != 0;
	if (!is_visible)
	{
		return;
	}
	const auto window_style = GetWindowLongA(hwnd, GWL_EXSTYLE);
	const auto is_top_most = (window_style & WS_EX_TOPMOST) != 0;
	const auto is_layered_window = (window_style & WS_EX_LAYERED) != 0;
	if (!is_top_most && !is_layered_window)
	{
		return;
	}

	RECT output_rect;
	GetWindowRect(hwnd, &output_rect);
	// ... проверка пересечений ...
}
void ac_log_cached_window_process(unsigned int pid, char* encrypted_string_buffer)
{
	const HANDLE process_handle = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, 0LL, pid);
	if (process_handle == INVALID_HANDLE_VALUE)
	{
		return;
	}

	DWORD lpcbNeeded = 0;
	HMODULE modules[1024];
	if (K32EnumProcessModules(process_handle, modules, 0x2000LL, &lpcbNeeded))
	{
		for (auto current_module_index = 0; ; ++current_module_index)
		{
			if (current_module_index >= lpcbNeeded / 8uLL)
				break;

			const auto current_module = modules[current_module_index];
			WCHAR wide_module_name[260];

			if (K32GetModuleFileNameExW(process_handle, current_module, wide_module_name, 260LL))
			{
				char ascii_module_name[1568];
				WideCharToMultiByte(65001LL, 0LL, wide_module_name, 0xFFFFFFFFLL, ascii_module_name, 1560, 0LL, 0LL);
				ac_string_encrypt(encrypted_string_buffer, ascii_module_name);
			}
		}
	}

	CloseHandle(process_handle);
}
void ac_handle_window(HWND hwnd, char* encrypted_string_thing)
{
	wchar_t window_text_WIDE[512]{0};
	GetWindowTextW(hwnd, window_text_WIDE, 512LL);

	char window_text_asci[3072]{0};
	WideCharToMultiByte(65001LL, 0LL, window_text_WIDE, 0xFFFFFFFFLL, window_text_asci, 3072, 0LL, 0LL);

	char window_class_name[256]{0};
	GetClassNameA(hwnd, window_class_name, 256LL);

	RECT window_rect;
	GetWindowRect(hwnd, &window_rect);

	const auto window_gwl_style = GetWindowLongA(hwnd, GWL_STYLE);
	const auto window_gwl_ex_style = GetWindowLongA(hwnd, GWL_EXSTYLE);

	DWORD display_affinity = 0;
	GetWindowDisplayAffinity(hwnd, &display_affinity);

	ac_string_encrypt(encrypted_string_thing, window_text_asci);
	ac_string_encrypt(encrypted_string_thing, window_class_name);

	ac_fmt_sprint_encrypt(encrypted_string_thing, 32, "%li", window_rect.left);
	ac_fmt_sprint_encrypt(encrypted_string_thing, 32, "%li", window_rect.top);
	ac_fmt_sprint_encrypt(encrypted_string_thing, 32, "%li", window_rect.right);
	ac_fmt_sprint_encrypt(encrypted_string_thing, 32, "%li", window_rect.bottom);
	ac_fmt_sprint_encrypt(encrypted_string_thing, 32, "%li", window_gwl_style);
	ac_fmt_sprint_encrypt(encrypted_string_thing, 32, "%li", window_gwl_ex_style);
	ac_fmt_sprint_encrypt(encrypted_string_thing, 32, "%lu", display_affinity);

	DWORD pid = 0;
	if (GetWindowThreadProcessId(hwnd, &pid))
	{
		ac_log_cached_window_process(pid, encrypted_string_thing);
	}
	else
	{
		// ...
	}
}

В итоге формируется нечто в стиле JSON со списком модулей, загруженных в обнаруженном оверлее, и прочими параметрами.


Как TAC обнаруживает инструменты вроде Cheat Engine?

Cheat Engine (CE) обычно сканирует память процесса, что приводит к «размораживанию» (commit) предварительно зарезервированной, но не используемой области памяти (через VirtualAlloc). TAC может намеренно выделить такую область, а затем периодически проверять, не была ли она задействована (через K32QueryWorkingSetEx). Если флаг в PSAPI_WORKING_SET_EX_INFORMATION стал «1», значит кто-то читал эти байты.

void run_honey_pot_violation(fn callback)
{
  const auto allocated_virtual_memory = VirtualAlloc(nullptr, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

  PSAPI_WORKING_SET_EX_INFORMATION working_set_information;
  memset(&working_set_information, 0, sizeof(working_set_information));

  working_set_information.VirtualAddress = allocated_virtual_memory;

  while (true)
  {
    const auto did_it_work = K32QueryWorkingSetEx((HANDLE)-1, &working_set_information, sizeof(working_set_information));
    if (did_it_work && (working_set_information.VirtualAttributes.Flags & 1) != 0 )
    {
      printf("XD CHEAT ENGINE DETECTED HAHAHAH\n");
      callback();
    }

    Sleep(1000);
  }
}

Anti-Sig Scanning (Защита от сигнатурного сканирования)

Разработчики читов часто применяют сигнатурное сканирование. Treyarch решило вставить «функцию-приманку», которая защищает возвращаемый адрес return address с помощью PAGE_NOACCESS. Поскольку код не вызывается повторно, это не мешает работе игры, но ломает сигнатурный сканер, читающий байты в зоне PAGE_NOACCESS.

void enable_anti_sig_scanning(fn callback)
{
  DWORD old = 0;

  const auto cpu_stamp = __rdtsc();
  unsigned __int64 protect_location = reinterpret_cast(_ReturnAddress());

  if ( (protect_location & cpu_stamp) + (protect_location | cpu_stamp) - (protect_location + cpu_stamp) )
  {
    if ( (cpu_stamp & 1) == 0 )
    {
  	  protect_location = (protect_location + 5120) & 0xFFFFFFFFFFFFF000uLL;
    }

    if (!VirtualProtect(reinterpret_cast<void*>(protect_location), 1, PAGE_NOACCESS, &old) )
    {
      callback();
    }
  }
}

При попытке сигнатурного сканирования по всей памяти это место вызовет AV (Access Violation).


Anti-Debugging (Анти-отладка)

Как TAC мешает отладке?

Arxan уже обеспечивает множество анти-отладочных механизмов (например, завершение при обнаружении debugger). В самом TAC есть и дополнительные проверки.

Проверка потоков на DebugObject

TAC через CreateToolhelp32Snapshot и OpenThread проходит по всем потокам процесса и проверяет наличие DebugObject. Если поток связан с отладчиком, процесс завершается.

void ac_loop_threads_debug(fn callback)
{
	HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, GetCurrentProcessId());

	THREADENTRY32 te32{};
	te32.dwSize = sizeof(te32);

	do
	{
		if (te32.th32OwnerProcessID != GetCurrentProcessId())
		{
			continue;
		}

		const HANDLE thread_handle = OpenThread(THREAD_ALL_ACCESS, FALSE, te32.th32ThreadID);
		if (thread_handle)
		{
			HANDLE debug_object_handle = INVALID_HANDLE_VALUE;

			ULONG ret_length = 0;
			THREAD_BASIC_INFORMATION thread_basic_information;

			if (!NtQueryInformationThread(thread_handle, 0, &thread_basic_information, sizeof(thread_basic_information), &ret_length))
			{
              if (thread_basic_information.TebBaseAddress)
              {
                if (thread_basic_information.TebBaseAddress->DbgSsReserved[1])
                {
                  debug_object_handle = HANDLE(thread_basic_information.TebBaseAddress->DbgSsReserved[1]);
                }
              }
			}

			if (debug_object_handle != INVALID_HANDLE_VALUE)
			{
				cb();
			}

			CloseHandle(thread_handle);
		}
	}
	while (Thread32Next(snapshot, &te32));
	CloseHandle(snapshot);
}

Генерация исключения STATUS_PRIVILEGED_INSTRUCTION

Античит намеренно вызывает нарушение доступа (запись по невалидному адресу). Если этот код продолжит выполняться дальше, значит отладчик его «поймал» и обошёл.

void ac_exception_anti_debug(fn callback)
{
  // это запись по невалидной памяти и выброс исключения
  __sidt((void *)0xFFFFFF8000000900LL); 
  callback(); // эта строчка в норме не должна выполняться
}

Стандартная проверка через CheckRemoteDebuggerPresent

__forceinline void ac_check_remote_debugger(callback cb)
{
  BOOL dbg = false;
  if (CheckRemoteDebuggerPresent((HANDLE)-1, &dbg))
  {
    if (dbg)
    {
      cb();
      // процесс закроется
    }
  }
}

ThreadHideFromDebugger

Чтобы затруднить работу отладчика, TAC использует ThreadHideFromDebugger. После установки этого флага исключения в потоке перестают отправляться отладчику и процесс аварийно завершается.

Эта опция устанавливается ещё в TLS Callback до входа в main():

__forceinline void ac_hide_current_thread()
{
  char use_ThreadHideFromDebugger = 1;
  nt_set_information_thread((HANDLE)-2, ThreadHideFromDebugger, (void**)&use_ThreadHideFromDebugger, 0);
}

Мониторинг сетевого трафика

Некоторые читы создают в процессе игры «локальный сервер», чтобы общаться с внешним приложением. TAC проверяет таблицу TCP-соединений (GetTcpTable2), ищет случаи, когда локальный порт совпадает с портом процесса игры, но PID другой. Если такие соединения есть, это становится поводом для флага.

// custom tac struct
struct tcp_entry
{
  DWORD OwningPid;
  DWORD LocalAddr;
  DWORD RemoteAddr;
  DWORD LocalPort;
  DWORD RemotePort;
};

void ac_detect_local_command_center(fn callback)
{
    WSAData data;
    WSAStartup(MAKEWORD(2, 2), &data);

    // ...
    // вызовы GetTcpTable2, анализ OwningPid, LocalPort, RemotePort
    // ...
}

Encrypted Custom Syscalls (Зашифрованные пользовательские системные вызовы)

Обычно в ntdll экспортированы функции, каждая из которых вызывает системный вызов (syscall). Однако TAC реализует собственные «запутанные» shelli-фрагменты, позволяющие совершать syscall напрямую, без вызова стандартных стубов из ntdll.dll, чтобы обойти хуки читов и усложнить инструментальные колбэки.

Пример стандартного syscall stub:

mov r10, rcx
mov eax, 0x11B
syscall
ret

TAC же динамически генерирует подобный код, шифрует его и хранит в выделенной в .text области, а при запуске расшифровывает и выполняет. Кроме того, TAC «маскируется» под случайные участки ntdll (например, якобы вызывает NtReadFile, в то время как фактически это может быть любой другой syscall). В результате инструментация от чит-разработчика видит «ложный» источник системного вызова.

Пример упрощённой схемы генерации:

auto ac_NtReadFile_1 = (char*)GetProcAddress(GetModuleHandleA("ntdll"), "NtReadFile");
volatile __int64 syscall_stub_memory = (__int64)VirtualAlloc(...);
__int64 syscall_index = 0;
// ...
*(_QWORD*)(syscall_stub_memory + offset_that_doesnt_matter + 28LL) = (__int64)nt_read_file_syscall_instruction;
// ...

При анализе подобного кода в IDA Pro возникает путаница, так как в регистре eax может находиться номер системного вызова любого API, а сам syscall берётся из места внутри NtReadFile.


Обнаружение ложного ThreadHideFromDebugger

Некоторые читы могут «хукать» NtSetInformationThread, чтобы всегда возвращать успех или ошибку. TAC использует несколько «кривых» вызовов, специально передавая неправильные параметры, дабы выявить плохую реализацию хука.

#define ThreadHideFromDebugger 17
#define NT_SUCCESS(Status) (((NTSTATUS)(Status)) >= 0)

__forceinline void ac_detect_hidden_thread(callback cb)
{
	HANDLE current_thread_handle = (HANDLE)-2;
	char use_ThreadHideFromDebugger = 0;

	// этот вызов должен завершиться ошибкой (STATUS_INFO_LENGTH_MISMATCH),
	// если он пройдет успешно, значит хук работает неправильно
	NTSTATUS query_result_1 = nt_set_information_thread(
                              current_thread_handle,
                              ThreadHideFromDebugger, 
                              (void**)&use_ThreadHideFromDebugger, 
                              1);
	if (NT_SUCCESS(query_result_1))
	{
		printf("fake call passed\n");
		cb();
	}

	// этот вызов всегда должен возвращать 0
	NTSTATUS query_result_2 = nt_set_information_thread(current_thread_handle, ThreadHideFromDebugger, 0LL, 0LL);
	if (query_result_2 < 0)
	{
		printf("second call failed\n");
		cb();
	}

	// проверка вызова NtQueryInformationThread с неправильным размером (4 вместо 1)
	NTSTATUS query_result_3 = nt_query_information_thread(
                              current_thread_handle, 
                              ThreadHideFromDebugger, 
                              (void**)&use_ThreadHideFromDebugger, 
                              4LL, 
                              NULL);

    if (NT_SUCCESS(query_result_3))
    {
    	printf("third call succeeded\n");
    	cb();
    }

	// передача фейкового хендла
    HANDLE fake_handle = (HANDLE)__rdtsc();
    NTSTATUS query_result_4 = nt_set_information_thread(fake_handle, ThreadHideFromDebugger, 0LL, 0LL);
    if (NT_SUCCESS(query_result_3))
    {
    	printf("fourth call succeeded\n");
    	cb();
    }
}

Это результат работы под x64dbg с ScyllaHide:

А это результат без отладчика и без ScyllaHide:


Create Remote Thread Blocking (Блокировка CreateRemoteThread)

TAC устанавливает собственный векторный обработчик исключений (VEH), который при возникновении STATUS_PRIVILEGED_INSTRUCTION вызывает TerminateThread для текущего потока. Создание нового потока из внешнего процесса (CreateRemoteThread) при «ручном маппинге» читов может привести к срабатыванию TLS Callback в загружаемом модуле. В этом колбэке TAC проверяет «адрес старта» (StartAddress) нового потока через NtQueryInformationThread, сверяет с загруженными модулями. Если адрес не попадает ни в один из модулей, поток обрывается.

VOID WINAPI tls_callback(PVOID DllHandle, DWORD Reason, PVOID Reserved)
{
  if (Reason == DLL_THREAD_ATTACH)
  {
      __int64 start_address = 0;
      NtQueryInformationThread(NtCurrentThread(), ThreadQuerySetWin32StartAddress, &start_address, sizeof(start_address), nullptr);

      bool outside_of_valid_module = true;

      const auto memory_module_list =
        &reinterpret_cast(NtCurrentTeb())->ProcessEnvironmentBlock->Ldr->InMemoryOrderModuleList;

      for (PLIST_ENTRY p_list_entry = memory_module_list->Flink; p_list_entry != memory_module_list; p_list_entry = p_list_entry->Flink)
      {
          auto p_entry = CONTAINING_RECORD(p_list_entry, nt::LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);

          if (start_address > reinterpret_cast(p_entry->DllBase)
           && start_address < reinterpret_cast(p_entry->DllBase) + p_entry->SizeOfImage)
          {
              outside_of_valid_module = false;
              break;
          }
      }

      if (outside_of_valid_module)
      {
          callback(); // detection is stored and uploaded later
          _priv_ins_exx();
      }
  }
}

Mystery Tech (Сомнительные проверки)

Есть проверка, которая следит за AllocationGranularity в SYSTEM_BASIC_INFORMATION. Если значение не равно 0x10000, то это расценивается как флаг (возможно, виртуальная машина или модифицированная система).

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

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

Есть также проверка целостности списка загруженных модулей (InMemoryOrderModuleList). Если он пустой, вызывается флаг.

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

Итог

TAC представляет собой продвинутый пользовательский античит, «спаянный» с игрой, без драйверов ядра, но со множеством проверок:

  • Проверка и резолв API во время выполнения.
  • Обнаружение хуков по конкретным паттернам в функциях, которые античит использует сам.
  • Защита от статического анализа и отладки (Arxan).
  • Мониторинг для debug-регистров (DR0–DR3).
  • Блокировка «Test Mode» в Windows.
  • Аварийный выход через зашифрованные шелл-коды.
  • Проверка на наличие консоли (AllocConsole).
  • Поиск внешних наложений (overlay) и сбор данных об их окнах и модулях.
  • «Медовые» страницы памяти для обнаружения сканирования памяти (Cheat Engine и аналогов).
  • Анти-сигнатурное сканирование при помощи PAGE_NOACCESS.
  • Простейшие анти-отладочные методы (CheckRemoteDebuggerPresent, проверка DebugObject).
  • Продвинутые syscall-стабы, «замаскированные» под другие вызовы.
  • Проверка корректности хука ThreadHideFromDebugger.
  • Защита от CreateRemoteThread через TLS Callback.

Всё это дополняется механизмом Arxan, который сам по себе предоставляет обфускацию, анти-отладку и контроль за целостностью .text-сегмента. Похожие подходы заметны и в новых играх серии Call of Duty. В целом, это любопытная и довольно сложная система защиты. SSNO надеется, что изложенная информация окажется интересной и полезной. Исследование TAC продолжается, так что в будущем, возможно, появятся новые детали.


Антивирус для мозга!

Лечим цифровую неграмотность без побочных эффектов

Активируйте защиту — подпишитесь