Углубленное исследование механизмов работы защиты от читеров TAC, особенностей реализации функций и методов обнаружения внешнего вмешательства в Black Ops Cold War.
Исследователь ssno уже некоторое время занимается реверс-инжинирингом Black Ops Cold War и решил поделиться своими исследованиями, касающимися пользовательского античита в игре. Данный материал не несет в себе цели очернять разработчиков и пропагандировать читы или методы обхода античита, поэтому некоторые детали были отредактированы.
Чтобы прояснить возможные недоразумения: в Black Ops Cold War нет драйвера в пространстве ядра (kernel-mode) Ricochet, который используется в Modern Warfare (2019) и более поздних играх серии. В представленном материале этот античит будет называться TAC (Treyarch Anti-Cheat), поскольку игра, подвергавшаяся реверс-инжинирингу, является проектом Treyarch. Также при приведении псевдокода функций стоит учитывать, что фактическая декомпиляция сильно усложнена большим количеством «мусорного» и «резолвящего» кода. Основное отличие более новых игр — наличие драйвера в пространстве ядра, при том что большая часть кода античита по-прежнему находится в пространстве пользователя и во многом схожа с TAC.
Ниже рассмотрены методы защиты самой игры и античита, прежде чем перейти к детальному описанию.
Arxan — это инструмент обфускации/защиты, применяемый во многих играх серии Call of Duty (чаще всего во всех проектах, вышедших после Black Ops 3). В его состав входят различные механизмы, существенно осложняющие жизнь разработчикам читов и специалистам по реверс-инжинирингу.
Исполняемый файл игры упакован и зашифрован; Arxan внедряет код в процессе запуска, чтобы распаковать и расшифровать основной .exe игры.
Arxan непрерывно отслеживает целостность .exe-файла, проверяя, не вносятся ли в него какие-либо изменения. Если 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 ; etc
Анализировать такие функции статически очень непросто, особенно если это большие фрагменты кода с множеством переходов.
Точка входа в игру также защищена Arxan. Сначала выполняется защищённый код, который распаковывает и запускает настоящую точку входа, а затем, при необходимости, дополнительно добавляются те же «jmp-заглушки», усложняя понимание происходящего.
Длительное время считалось, что это часть Arxan, однако согласно недавним данным, механизм шифрования указателей — это наработки Treyarch, которыми они могли делиться с Infinity Ward (или наоборот). Ключевые указатели — например, текущая глобальная структура игры (game glob), массив сущностей (entity array), указатели объектов и т. д. — постоянно зашифрованы и расшифровываются непосредственно перед использованием.
Получить расшифрованные указатели можно несколькими способами (не единственно возможными):
__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 ориентирован на 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);
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, которые ему нужны для внутренней работы. Это минимальный набор проверок, направленных на защиту от вмешательства в код античита.
В условиях 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 проверяет, не установлены ли какие-либо аппаратные точки останова.
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(); } }
Античит использует два способа завершения игры, предварительно обнуляя все регистры. Оба они оформлены как «встроенный шелл-код» (inline shelli).
Первый метод (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 или меню, внутренние читы часто перехватывают (hook) графический API, например, DirectX 12 (используемый современной линейкой Call of Duty). Распространённая точка перехвата — это функция IDXGISwapChain::Present. Или же, в случае DirectX 12, могут перехватываться методы ID3D12CommandQueue::ExecuteCommandLists.
В данном случае 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 со списком модулей, загруженных в обнаруженном оверлее, и прочими параметрами.
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); } }
Разработчики читов часто применяют сигнатурное сканирование. 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).
Arxan уже обеспечивает множество анти-отладочных механизмов (например, завершение при обнаружении debugger). В самом TAC есть и дополнительные проверки.
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); }
Античит намеренно вызывает нарушение доступа (запись по невалидному адресу). Если этот код продолжит выполняться дальше, значит отладчик его «поймал» и обошёл.
void ac_exception_anti_debug(fn callback) { // это запись по невалидной памяти и выброс исключения __sidt((void *)0xFFFFFF8000000900LL); callback(); // эта строчка в норме не должна выполняться }
__forceinline void ac_check_remote_debugger(callback cb) { BOOL dbg = false; if (CheckRemoteDebuggerPresent((HANDLE)-1, &dbg)) { if (dbg) { cb(); // процесс закроется } } }
Чтобы затруднить работу отладчика, 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 // ... }
Обычно в 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.
Некоторые читы могут «хукать» 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:
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(); } } }
Есть проверка, которая следит за 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 представляет собой продвинутый пользовательский античит, «спаянный» с игрой, без драйверов ядра, но со множеством проверок:
Всё это дополняется механизмом Arxan, который сам по себе предоставляет обфускацию, анти-отладку и контроль за целостностью .text-сегмента. Похожие подходы заметны и в новых играх серии Call of Duty. В целом, это любопытная и довольно сложная система защиты. SSNO надеется, что изложенная информация окажется интересной и полезной. Исследование TAC продолжается, так что в будущем, возможно, появятся новые детали.
Лечим цифровую неграмотность без побочных эффектов