[Перевод] Изменения в популярном античите BattlEye и способы их обхода
Основные обновления шелл-кода BattlEye
Время идёт, античиты меняются, и для повышения эффективности продукта в них появляются и исчезают функции. Год назад я подготовил подробное описание шелл-кода BattlEye в своём блоге [перевод на Хабре], и эта часть статьи станет простым отражением изменений, внесённых в шелл-код.
Чёрный список временных меток
В последнем анализе BattlEye, в списке теневого бана было всего две метки дат времени компиляции, и похоже, что разработчики решили добавить гораздо больше:
0x5B12C900 (action_x64.dll)
0x5A180C35 (TerSafe.dll, Epic Games)
0xFC9B9325 (?)
0x456CED13 (d3dx9_32.dll)
0x46495AD9 (d3dx9_34.dll)
0x47CDEE2B (d3dx9_32.dll)
0x469FF22E (d3dx9_35.dll)
0x48EC3AD7 (D3DCompiler_40.dll)
0x5A8E6020 (?)
0x55C85371 (d3dx9_32.dll)
0x456CED13 (?)
0x46495AD9 (D3DCompiler_40.dll)
0x47CDEE2B (D3DX9_37.dll)
0x469FF22E (?)
0x48EC3AD7 (?)
0xFC9B9325 (?)
0x5A8E6020 (?)
0x55C85371 (?)
Мне не удалось идентифицировать оставшиеся временные метки, а два 0xF******* — это хеши, созданные детерминированными сборками Visual Studio. Благодарю @mottikraus и T0B1 за идентификацию некоторых временных меток.
Проверки модулей
Как показал основной анализ, ключевой особенностью BattlEye является перебор модулей, и с момента прошлого анализа в список был добавлен ещё один модуль:
void battleye::misc::module_unknown1()
{
if (!GetProcAddress(current_module, "NSPStartup"))
return;
if (optional_header.data_directory[4].size == 0x1B20 ||
optional_header.data_directory[4].size == 0xE70 ||
optional_header.data_directory[4].size == 0x1A38 ||
timestamp >= 0x5C600000 && timestamp < 0x5C700000)
{
report_module_unknown report = {};
report.unknown = 0;
report.report_id = 0x35;
report.val1 = 0x5C0;
report.timestamp = timestamp;
report.image_size = optional_header.size_of_image;
report.entrypoint = optional_header.address_of_entry_point;
report.directory_size = optional_header.data_directory[4].size;
battleye::report(&report, sizeof(report), false);
}
}
Вероятно, это обнаружение определённых прокси-dll, так как здесь проверяется размер таблицы переадресации.
Заголовки окон
В предыдущем анализе при помощи названий окон помечались флагами различные поставщики читов, но с тех пор шелл-код перестал проверять эти заголовки окон. Список заголовков окон был полностью заменён на:
Chod's
Satan5
Названия образов
BattlEye печально известен тем, что использует очень примитивные методы обнаружения, и одним из них является чёрный список названий образов. С каждым годом список забаненных названий образов становится всё длиннее, а за последние 11 месяцев были добавлены пять новых:
frAQBc8W.dll
C:\\Windows\\mscorlib.ni.dll
DxtoryMM_x64.dll
Project1.dll
OWClient.dll
Стоит заметить, что присутствие модуля с названием, соответствующим любому из пунктов списка, не будет означать, что вас сразу же забанят. Механизм создания отчётов также передаёт базовую информацию о модуле, которая скорее всего используется для того, чтобы отличить читы от коллизий на сервере BattlEye.
7-Zip
7-Zip широко использовался и продолжает использоваться участниками чит-сцены как заполнитель памяти для пустот кода (code-caves). BattlEye пытается бороться с этим, выполняя очень плохую проверку целостности, которую со времени моей предыдущей статьи изменили:
void module::check_7zip()
{
const auto module_handle = GetModuleHandleA("..\\..\\Plugins\\ZipUtility\\ThirdParty\\7zpp\\dll\\Win64\\7z.dll");
// --- REMOVED ---
// if (module_handle && *(int*)(module_handle + 0x1000) != 0xFF1441C7)
// --- ADDED ---
if (module_handle && *(int*)(module_handle + 0x1008) != 0x83485348)
{
sevenzip_report.unknown_1 = 0;
sevenzip_report.report_id = 0x46;
sevenzip_report.unknown_2 = 0;
sevenzip_report.data1 = *(__int64*)(module_handle + 0x1000;
sevenzip_report.data2 = *(__int64*)(module_handle + 0x1008;
battleye::report(&sevenzip_report, sizeof(sevenzip_report), false);
}
}
Похоже, разработчики BattlEye догадались, что моя предыдущая статья привела к тому, что многие пользователи обходят эту проверку, просто копируя нужные байты в место, проверяемое BattlEye. Как же они исправили ситуацию? Сместили проверку на восемь байтов и продолжили использовать тот же плохой способ проверки целостности. Исполняемый раздел read-only, и всё, что вам нужно сделать — загрузить 7-Zip с диска и сравнить перемещённые разделы друг с другом; если есть какие-то расхождения, то что-то не так. Серьёзно, ребята, выполнять проверки целостности не так сложно.
Проверка сети
Перебор таблицы TCP по-прежнему работает, но после того, как я выпустил предыдущий анализ, критикующий разработчиков за пометку флагами IP-адресов Cloudflare, они всё-таки убрали эту проверку. Античит всё равно сообщает о порте, который использует для соединения xera.ph, но разработчики добавили новую проверку, чтобы определять, есть ли у процесса с соединением активная защита (предположительно, это выполняется при помощи обработчика).
void network::scan_tcp_table
{
memset(local_port_buffer, 0, sizeof(local_port_buffer);
for (iteration_index = 0; iteration_index; < 500 ++iteration_index)
{
// GET NECESSARY SIZE OF TCP TABLE
auto table_size = 0;
GetExtendedTcpTable(0, &table_size, false, AF_INET, TCP_TABLE_OWNER_MODULE_ALL, 0);
// ALLOCATE BUFFER OF PROPER SIZE FOR TCP TABLE
auto allocated_ip_table = (MIB_TCPTABLE_OWNER_MODULE*)malloc(table_size);
if (GetExtendedTcpTable(allocated_ip_table, &table_size, false, AF_INET, TCP_TABLE_OWNER_MODULE_ALL, 0) != NO_ERROR)
goto cleanup;
for (entry_index = 0; entry_index < allocated_ip_table->dwNumEntries; ++entry_index)
{
// --- REMOVED ---
// const auto ip_address_match_1 =
// allocated_ip_table->table[entry_index].dwRemoteAddr == 0x656B1468; // 104.20.107.101
//
// const auto ip_address_match_2 =
// allocated_ip_table->table[entry_index].dwRemoteAddr == 0x656C1468; // 104.20.108.101
// +++ ADDED +++
const auto target_process = OpenProcess(QueryLimitedInformation, 0, ip_table->table[entry_index].dwOwningPid);
const auto protected = target_process == INVALID_HANDLE && GetLastError() == 0x57;
if (!protected)
{
CloseHandle(target_process);
return;
}
const auto port_match =
allocated_ip_table->table[entry_index].dwRemotePort == 20480;
for (port_index = 0;
port_index < 10 &&
allocated_ip_table->table[entry_index].dwLocalPort !=
local_port_buffer[port_index];
++port_index)
{
if (local_port_buffer[port_index])
continue
tcp_table_report.unknown = 0;
tcp_table_report.report_id = 0x48;
tcp_table_report.module_id = 0x5B9;
tcp_table_report.data =
BYTE1(allocated_ip_table->table[entry_index].dwLocalPort) |
(LOBYTE(allocated_ip_table->table[entry_index.dwLocalPort) << 8;
battleye::report(&tcp_table_report, sizeof(tcp_table_report), false);
local_port_buffer[port_index] = allocated_ip_table->table[entry_index].dwLocalPort;
break
}
}
cleanup:
// FREE TABLE AND SLEEP
free(allocated_ip_table);
Sleep(10
}
}
Благодарю IChooseYou и abstract
Обход по стеку BattlEye
Взлом игр — постоянная игра в кошки-мышки, поэтому слухи о новых приёмах распространяются как пожар. В этой части мы рассмотрим новые эвристические техники, которые недавно добавил в свой арсенал крупный поставщик античитов BattlEye. Чаще всего эти техники называют обходом по стеку (stack walking). Обычно они реализуются обработкой функции и проходом по стеку, чтобы выяснить, кто же конкретно вызвал эту функцию. Зачем это нужно делать? Как и любая другая программа, хаки видеоигр имеют набор хорошо известных функций, которые они используют для получения информации от клавиатуры, вывода в консоль или вычисления определённых математических выражений. Кроме того, хаки видеоигр любят скрывать своё существование, будь то в памяти или на диске, чтобы античитерское ПО их не нашло. Но что забывают читерские программы, так это то, что регулярно вызывают функции из других библиотек, и это можно использовать для эвристического обнаружения неизвестных читов. Реализовав движок обхода по стеку для таких функций, как std::print
, мы сможем найти эти читы, даже если они маскируются.
BattlEye реализовал «обход по стеку», несмотря на то, что публично об этом не заявлялось и на момент выпуска статьи оставалось только слухами. Обратите внимание на кавычки — то, что вы здесь увидите, на самом деле не настоящий обход по стеку, а просто сочетание проверки обратного адреса и дампа вызывающей программы. Настоящая реализация обхода по стеку проходила бы по стеку и генерировала настоящий стек вызовов.
Как я объяснял в предыдущей статье про BattlEye, система античита динамически выполняет потоковую передачу шелл-кода в процесс игры, когда она запущена. Эти шелл-коды имеют разные размеры и задачи, и не передаются одновременно. Замечательное свойство подобной системы заключается в том, что исследователям требуется динаические анализировать античит, в процессе мультиплеерного матча, что усложняет определение характеристик этого античита. Также это позволяет античиту применять к разным пользователям различные меры, например, передавать более глубоко инвазивный модуль только тому человеку, который имеет необычно высокое соотношение убийств и смертей, и тому подобное.
Один из таких шелл-кодов BattlEye отвечает за выполнение этого анализа стека; мы будем называет его shellcode8kb, потому что он немного меньше по сравнению с shellcodemain, который я задокументировал здесь. Этот небольшой шелл-код при помощи функции AddVectoredExceptionHandler подготавливает векторизированный обработчик исключений, а затем устанавливает ловушки прерываний на следующих функциях:
GetAsyncKeyState
GetCursorPos
IsBadReadPtr
NtUserGetAsyncKeyState
GetForegroundWindow
CallWindowProcW
NtUserPeekMessage
NtSetEvent
sqrtf
__stdio_common_vsprintf_s
CDXGIFactory::TakeLock
TppTimerpExecuteCallback
Для этого он просто итеративно обходит список стандартно используемых функций, присваивая первую инструкцию соответствующей функции значение int3, которое используется как точка останова. После установки точки останова все вызовы соответствующей функции проходят через обработчик исключений, имеющий полный доступ к регистрам и стеку. Имея этот доступ, обработчик исключений создаёт дамп адреса вызывающей программы из вершины стека, и в случае выполнения одного из эвристических условий 32 байта вызывающей функции дампятся и отправляются на сервера BattlEye с идентификатором отчёта 0×31:
__int64 battleye::exception_handler(_EXCEPTION_POINTERS *exception)
{
if (exception->ExceptionRecord->ExceptionCode != STATUS_BREAKPOINT)
return 0;
const auto caller_function = *(__int64 **)exception->ContextRecord->Rsp;
MEMORY_BASIC_INFORMATION caller_memory_information = {};
auto desired_size = 0;
// QUERY THE MEMORY PAGE OF THE CALLER
const auto call_failed = NtQueryVirtualMemory(
GetCurrentProcess(),
caller_function,
MemoryBasicInformation,
&caller_memory_information,
sizeof(caller_memory_information),
&desired_size) < 0;
// IS THE MEMORY SOMEHOW NOT COMMITTED? (WOULD SUGGEST VAD MANIPULATIUON)
const auto non_commit = caller_memory_information.State != MEM_COMMIT;
// IS THE PAGE EXECUTABLE BUT DOES NOT BELONG TO A PROPERLY LOADED MODULE?
const auto foreign_image = caller_memory_information.Type != MEM_IMAGE && caller_memory_information.RegionSize > 0x2000;
// IS THE CALL BEING SPOOFED BY NAMAZSO?
const auto spoof = *(_WORD *)caller_function == 0x23FF; // jmp qword ptr [rbx]
// FLAG ALL ANBORMALITIES
if (call_failed || non_commit || foreign_image || spoof)
{
report_stack.unknown = 0;
report_stack.report_id = 0x31;
report_stack.hook_id = hook_id;
report_stack.caller = (__int64)caller_function;
report_stack.function_dump[0] = *caller_function;
report_stack.function_dump[1] = caller_function[1];
report_stack.function_dump[2] = caller_function[2];
report_stack.function_dump[3] = caller_function[3];
if (!call_failed)
{
report_stack.allocation_base = caller_memory_information.AllocationBase;
report_stack.base_address = caller_memory_information.BaseAddress;
report_stack.region_size = caller_memory_information.RegionSize;
report_stack.type_protect_state = caller_memory_information.Type | caller_memory_information.Protect | caller_memory_information.State;
}
battleye::report(&report_stack, sizeof(report_stack), false);
return -1;
}
}
Как мы видим, обработчик исключений выполняет дамп всех вызывающих функций в случае бесцеремонного изменения страницы памяти или когда функция не принадлежит к известному модулю процесса (тип страницы памяти MEM_IMAGE не задан manualmapper-ами). Также он выполняет дамп вызывающих функций, когда не удаётся вызвать NtQueryVirtualMemory, чтобы читы не привязывались к этому системному вызову и не скрывали свой модуль от дампера стека. Последнее условие на самом деле довольно интересное, оно помечает все вызывающие функции, использующие гаджет jmp qword ptr [rbx] — способ, применяемый для «спуфинга обратного адреса». Он выпущен моим коллегой-участником тайного клуба с ником namazso. Похоже, разработчики BattlEye увидели, что люди пользуются этим способом спуфинга в их играх и решили нацелиться непосредственно на него. Здесь стоит упомянуть, что описанный namazsos способ работает хорошо, достаточно просто использовать другой гаджет, или полностью отличающийся, или просто другой регистр — это не важно.
Совет разработчикам BattlEye: используемый вами для поиска CDXGIFactory::TakeLock
в памяти неверен, потому что вы (случайно или намеренно) включили CC padding, который сильно отличается при каждой компиляции. Для максимальной совместимости нужно убрать padding (первый байт в сигнатуре) и так вы скорее всего поймаете больше читеров :)
Полная структура, отправляемая серверу BattlEye, выглядит так:
struct __unaligned battleye_stack_report
{
__int8 unknown;
__int8 report_id;
__int8 val0;
__int64 caller;
__int64 function_dump[4];
__int64 allocation_base;
__int64 base_address;
__int32 region_size;
__int32 type_protect_state;
};
Распознавание гипервизора в BattlEye
Игра в кошки-мышки в области взлома игр продолжает оставаться источником новаций в эксплойтах и борьбе с читами. Использование технологии виртуализации во взломе игр начало активно развиваться после появления таких простых в применении гипервизоров, как DdiMon Сатоси Танда и hvpp Петра Бенеша. Эти два проекта используются большинством платных читов андерграундной хакерской сцены благодаря низкому порогу вхождения и подробной документации. Эти релизы с большой вероятностью ускорили гонку вооружений в области гипервизоров, которая сейчас начинает проявляться в сообществе хакеров игр. Вот что об этой ситуации говорит администратор одного из крупнейших сообществ взлома игр под ником wlan:
С появлением готовых к использованию систем гипервизоров для взлома игр стало неизбежным то, что античиты наподобие BattlEye сосредоточатся на обобщённом распознавании виртуализации.
Широкое распространение гипервизоров объясняется недавними усовершенствованиями в античитах, которые оставили хакерам очень мало возможностей для модификации игр традиционными способами. Популярность гипервизоров можно объяснить простотой избегания античита, потому что виртуализация упрощает сокрытие информации при помощи таких механизмов, как syscall hooks и MMU virtualization.
Недавно в BattlEye было реализовано распознавание распространённых гипервизоров наподобие упомянутых выше платформ (DdiMon, hvpp) при помощи обнаружения на основе времени. Это распознавание пытается обнаружить нестандартные значения времени инструкции CPUID. CPUID — это относительно малозатратная на реальном оборудовании инструкция, обычно требующая всего двух сотен циклов, а в виртуальном окружении её выполнение может занимать в десять раз больше времени из-за лишних операций, вызываемых движком интроспекции. Движок интроспекции непохож на реальное оборудование, которое просто выполняет операцию ожидаемым образом, поскольку он на основании произвольного критерия отслеживает и условно изменяет данные, возвращаемые гостю.
Забавный факт: CPUID активно используется в этих процедурах временнОго распознавания, потому что это инструкция с безусловным выходом, а также инструкция с непривилегированной сериализацией. Это значит, что CPUID используется в качестве барьера и гарантирует, что инструкции до и после неё будут выполнены; тайминги при этом становятся независимыми от обычного переупорядочивания инструкций. Можно также использовать инструкции наподобие XSETBV, тоже выполняющих безусловный выход, но для обеспечения независимого тайминга для этого потребуется какая-нибудь барьерная инструкция, чтобы до или после неё не произошло никакого переупорядочивания, влияющего на надёжность таймингов.
Распознавание
Ниже представлена процедура распознавания из модуля BattlEye «BEClient2»; я выполнил её реверс-инжиниринг и воссоздал код на псевдо-C, а потом опубликовал его в twitter. Спустя день после моего твита разработчики BattlEye неожиданно изменили обфускацию BEClient2, видимо надеясь, что это помешает мне анализировать модуль. Предыдущая обфускация не менялась больше года, но изменилась на следующий день после моего твита о ней — впечатляющая скорость.
void battleye::take_time()
{
// SET THREAD PRIORITY TO THE HIGHEST
const auto old_priority = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL);
// CALCULATE CYCLES FOR 1000MS
const auto timestamp_calibrator = __rdtsc();
Sleep(1000);
const auto timestamp_calibration = __rdtsc() - timestamp_calibrator;
// TIME CPUID
auto total_time = 0;
for (std::size_t count = 0; count < 0x6694; count++)
{
// SAVE PRE CPUID TIME
const auto timestamp_pre = __rdtsc();
std::uint32_t cpuid_data[4] = {};
__cpuid(cpuid_data, 0);
// SAVE THE DELTA
total_time += __rdtsc() - timestamp_pre;
}
// SAVE THE RESULT IN THE GLOBAL REPORT TABLE
battleye::report_table[0x1A8] = 10000000 * total_time / timestamp_calibration / 0x65;
// RESTORE THREAD PRIORITY
SetThreadPriority(GetCurrentThread(), old_priority);
}
Как я говорил выше, это самая распространённая техника распознавания с использованием безусловно перехватываемых инструкций. Однако она уязвима перед подделкой времени, и об этом мы подробно расскажем в следующем разделе.
Обход распознавания
У такого способа распознавания есть проблемы. Во-первых, он подвержен подделке времени, которая обычно выполняется двумя способами: смещением TSC в VMCS или уменьшением TSC при каждом выполнении CPUID. Существует много других способов справиться с атаками на основе времени, но последний гораздо проще в реализации, потому что можно гарантировать, что время выполнения инструкции будет находиться в пределах одного-двух тактов синхронизации выполнения на реальном оборудовании. Сложность обнаружения этой техники подделки времени зависит от опыта разработчика. В следующем разделе мы рассмотрим распознавание подделки времени и улучшение реализации, созданной в BattlEye. Второй причиной изъяна этого способа распознавания является то, что задержка CPUID (время выполнения) в разных процессорах сильно отличается и в зависимости от значения листа. На выполнение может потребоваться время в пределах 70–300 тактов. Третья проблема этой процедуры распознавания заключается в использовании SetThreadPriority. Эта функция Windows используется для задания значения приоритета заданного дескриптора потока, однако ОС не всегда слушает запрос. Эта функция является просто предложением повышения приоритета потока, и нет гарантии, что оно произойдёт. Таким образом, появляется возможность того, что этот способ будет подвержен воздействию прерываний или других процессов.
Обойти распознавание в этом случае легко, и описанная техника подделки времени эффективно побеждает этот способ распознавания. Если разработчики BattlEye захотят улучшить этот способ, то в следующем разделе приведены некоторые рекомендации.
Усовершенствование
Эту функцию можно улучшить множеством способов. Во-первых, можно намеренно отключить прерывания и принудительно задать приоритет потока, изменив CR8 на самый высокий уровень IRQL. Также было бы идеально изолировать эту проверку в одном ядре ЦП. Ещё одно улучшение: следует использовать разные таймеры, однако многие из них не так точны, как TSC, но существует один такой таймер под названием таймер APERF, или Actual Performance Clock. Я рекомендую этот таймер, потому что с ним сложнее жульничать и он только накапливает счётчик, когда логический процессор находится в состоянии питания C0. Это великолепная альтернатива использованию TSC. Также можно использовать таймер ACPI, HPET, PIT, таймер GPU, таймер NTP или таймер PPERF, который похож на APERF, но считает такты, которые воспринимаются как выполнение инструкций. Недостаток этого заключается в том, что необходимо включение HWP, который может быть отключен промежуточным оператором, а потому оказывается бесполезным.
Ниже представлена улучшенная версия процедуры распознавания, которая должна выполняться в ядре:
void battleye::take_time()
{
std::uint32_t cpuid_regs[4] = {};
_disable();
const auto aperf_pre = __readmsr(IA32_APERF_MSR) << 32;
__cpuid(&cpuid_regs, 1);
const auto aperf_post = __readmsr(IA32_APERF_MSR) << 32;
const auto aperf_diff = aperf_post - aperf_pre;
// CPUID IET ARRAY STORE
// BATTLEYE REPORT TABLE STORE
_enable();
}
Примечание: IET означает Instruction Execution Time (время выполнения инструкции).
Тем не менее, процедура всё равно может быть очень ненадёжной в обнаружении распространённых гипервизоров, поскольку время выполнения CPUID может очень сильно варьироваться. Лучше было бы сравнивать IET двух инструкций. Одна из них должна иметь большую задержку выполнения, чем CPUID. Например, это может быть FYL2XP1 — арифметическая инструкция, выполнение которой занимает чуть больше времени, чем среднее IET инструкции CPUID. Кроме того, она не вызывает никаких ловушек в гипервизоре и её время можно надёжно замерить. При помощи этих двух функций функция профилирования могла бы создавать массив для хранения IET инструкций CPUID и FYL2XP1. При помощи таймера APERF можно было бы получать начальный такт арифметической инструкции, выполнять инструкцию и вычислять для неё дельту тактов. Результаты можно было бы сохранять в массив IET в течение N циклов профилирования, получая среднее значение, и повторять процесс для CPUID. Если время выполнения инструкции CPUID больше, чем у арифметической инструкции, то это надёжный признак того, что система виртуальна, потому что арифметическая инструкция ни при каких условиях не могла бы тратить больше времени, чем выполнение CPUID для получения информации о производителе или версии. Такая процедура распознавания также сможет обнаруживать тех, кто использует смещение/масштабирование TSC.
Повторюсь, разработчикам нужно было бы принудительно включить привязку к вычислительному ядру для выполнения этой проверки на одном ядре, отключить прерывания и принудительно задать IRQL максимальное значение, чтобы гарантировать согласующиеся и надёжные данные. Было бы удивительно, если бы разработчики BattlEye решили реализовать это, потому что для этого требуется гораздо больше усилий. В драйвере ядра BattlEye ест две другие процедуры распознавания виртуальных машин, но это тема для другой статьи.