[Перевод] Как мы взломали шифрование пакетов в BattlEye
Недавно Battlestate Games, разработчики Escape From Tarkov, наняли BattlEye для реализации шифрования сетевых пакетов, чтобы мошенники не могли перехватить эти пакеты, разобрать их и использовать в своих интересах в виде радарных читов или иным образом. Сегодня подробно расскажем, как мы взломали их шифрование спустя несколько часов.
Анализ EFT
Мы начали с анализа самого «Escape from Tarkov». В игре используется Unity Engine, который, в свою очередь, использует C# — промежуточный язык, а это означает, что можно очень легко просмотреть исходный код игры, открыв его в таких инструментах, как ILDasm или dnSpy. В этом анализе мы работали с dnSpy.
Unity Engine без опции IL2CPP генерирует игровые файлы и помещает их в GAME_NAME_Data\Managed, в нашем случае это EscapeFromTarkov_Data\Managed. Эта папка содержит все использующие движок зависимости, включая файл с кодом игры — Assembly-CSharp.dll, мы загрузили этот файл в dnSpy, а затем искали строку encryption и оказались здесь:
Этот сегмент находится в классе EFT.ChannelCombined, который, как можно судить по переданным ему аргументам, работает с сетью:
Правый клик по переменной channelCombined.bool_2, которая регистрируется как индикатор того, было ли включено шифрование, а затем клик по кнопке Analyze показывают нам, что на эту переменную ссылаются два метода:
Второй из них — тот, в котором мы сейчас находимся, так что, дважды щёлкнув по первому, мы окажемся здесь:
Вуаля! Есть вызов BEClient.EncryptPacket, клик по методу приведёт к классу BEClient, его мы можем препарировать и найти метод DecryptServerPacket. Этот метод вызывает функцию pfnDecryptServerPacket в библиотеке BEClient_x64.dll. Она расшифрует данные в пользовательском буфере и запишет размер расшифрованного буфера в предоставленный вызывающим методом указатель.
pfnDecryptServerPacket не экспортируется BattlEye и не вычисляется EFT, на самом деле он поставляется инициализатором BattlEye, который в какой-то момент вызывается игрой. Нам удалось вычислить RVA (Relative Virtual Address), загрузив BattlEye в свой процесс и скопировав то, как игра инициализирует его. Код этой программы лежит здесь.
Анализ BattlEye
В последнем разделе мы сделали вывод, что, чтобы выполнить все свои криптографические задачи, EFT вызывает BattlEye. Так что теперь речь идёт о реверс-инжинеринге не IL, а нативного кода, что значительно сложнее.
BattlEye использует защитный механизм под названием VMProtect, который виртуализирует и изменяет указанные разработчиком сегменты. Чтобы правильно выполнить реверс-инжинеринг защищённого этим обфускатором бинарника, нужно распаковать его.
Распаковка — это дамп образа процесса во время выполнения; мы сделали дамп, загрузив его в локальный процесс, а затем поработав в Scylla, чтобы сбросить его память на диск.
Открытие этого файла в IDA, а затем переход к процедуре DecryptServerPacket приведут нас к функции, которая выглядит так:
Это называется vmentry, она добавляет на стек vmkey, а затем вызывает обработчика виртуальной машины — vminit. Хитрость вот в чём: из-за того, что инструкции «виртуализированы» VMProtect, они понятны только самой программе.
К счастью для нас, участник Секретного Клуба can1357 сделал инструмент, который полностью ломает эту защиту, — VTIL; его вы найдёте здесь.
Выясняем алгоритм
Созданный VTIL файл сократил функцию с 12195 инструкций до 265, что значительно упростило проект. Некоторые процедуры VMProtect присутствовали в дизассемблированном коде, но они легко распознаются и их можно проигнорировать, шифрование начинается отсюда:
Вот эквивалент в псевдо-Си:
uint32_t flag_check = *(uint32_t*)(image_base + 0x4f8ac);
if (flag_check != 0x1b)
goto 0x20e445;
else
goto 0x20e52b;
VTIL использует свой собственный набор инструкций, чтобы ещё больше упростить код. Я перевёл его на псевдо-Си.
Мы анализируем эту процедуру, войдя в 0×20e445, который является переходом к 0×1a0a4a, в самом начале этой функции они перемещают sr12 — копию rcx (первый аргумент в соглашении о вызове x64 по умолчанию) — и хранят его на стеке в [rsp+0×68], а ключ xor — в [rsp+0×58]. Затем эта процедура переходит к 0×1196fd, вот он:
И вот эквивалент в псевдо-Си:
uint32_t xor_key_1 = *(uint32_t*)(packet_data + 3) ^ xor_key;
(void(*)(uint8_t*, size_t, uint32_t))(0x3dccb7)(packet_data, packet_len, xor_key_1);
Обратите внимание, что rsi — это rcx, а sr47 — это копия rdx. Так как это x64, они вызывают 0×3dccb7 с аргументами в таком порядке: (rcx, rdx, r8). К счастью для нас, vxcallq во VTIL означает вызов функции, приостановку виртуального выполнения, а затем возврат в виртуальную машину, так что 0×3dccb7 — не виртуализированная функция! Войдя в эту функцию в IDA и нажав F5, вы вызовете сгенерированный декомпилятором псевдокод:
Этот код выглядит непонятно, в нём какие-то случайные ассемблерные вставки, и они вообще не имеют значения. Как только мы отменим эти инструкции, изменим некоторые типы var, а затем снова нажмём F5, код будет выглядеть намного лучше:
Эта функция расшифровывает пакет в несмежные 4-байтовые блоки, начиная с 8-го байта, с помощью ключа шифра rolling xor.
Примечание от переводчика:
Rolling xor — шифр, при котором операция xor буквально прокатывается [отсюда rolling] по байтам:
Первый байт остаётся неизменным.
Второй байт — это результат xor первого и второго оригинальных байтов.
Третий байт — результат XOR изменённого второго и оригинального третьего байтов и так далее. Реализация здесь.
Продолжая смотреть на ассемблер, мы поймём, что она вызывает здесь другую процедуру:
Эквивалент на ассемблере x64:
mov t225, dword ptr [rsi+0x3]
mov t231, byte ptr [rbx]
add t231, 0xff ; uhoh, overflow
; the following is psuedo
mov [$flags], t231 u< rbx:8
not t231
movsx t230, t231
mov [$flags+6], t230 == 0
mov [$flags+7], t230 < 0
movsx t234, rbx
mov [$flags+11], t234 < 0
mov t236, t234 < 1
mov t235, [$flags+11] != t236
and [$flags+11], t235
mov rdx, sr46 ; sr46=rdx
mov r9, r8
sbb eax, eax ; this will result in the CF (carry flag) being written to EAX
mov r8, t225
mov t244, rax
and t244, 0x11 ; the value of t244 will be determined by the sbb from above, it'll be either -1 or 0
shr r8, t244 ; if the value of this shift is a 0, that means nothing will happen to the data, otherwise it'll shift it to the right by 0x11
mov rcx, rsi
mov [rsp+0x20], r9
mov [rsp+0x28], [rsp+0x68]
call 0x3dce60
Прежде чем мы продолжим разбирать вызываемую им функцию, мы должны прийти к следующему выводу: сдвиг бессмыслен из-за того, что флаг переноса не установлен, а это приводит к возвращаемому из инструкции sbb значению 0; в свою очередь, это означает, что мы не на правильном пути.
Если поискать ссылки на первую процедуру 0×1196fd, то увидим, что на неё действительно ссылаются снова, на этот раз с другим ключом!
Это означает, что первый ключ на самом деле направлял по ложному следу, а второй, скорее всего, правильный. Хороший Бастиан!
Теперь, когда мы разобрались с реальным ключом xor и аргументами к 0×3dce60, которые расположены в таком порядке: (rcx, rdx, r8, r9, rsp+0×20, rsp+0×28). Переходим к этой функции в IDA, нажимаем F5 — и теперь прочитать её очень легко:
Мы знаем порядок аргументов, их тип и значение; единственное, что осталось, — перевести наши знания в реальный код, который мы хорошо написали и завернули в этот gist.
Заключение
Это шифрование было не самым сложным для реверс-инжиниринга, и наши усилия, безусловно, были замечены BattlEye; через 3 дня шифрование было изменено на TLS-подобную модель, где для безопасного обмена ключами AES используется RSA. Это делает MITM без чтения памяти процесса неосуществимым во всех смыслах и целях.
Если вам близка сфера информационной безопасности — то вы можете обратить свое внимание на наш специальный курс Этичный хакер, на котором мы учим студентов искать уязвимости даже в самых надежных системах и зарабатывать на этом.
Узнайте, как прокачаться и в других специальностях или освоить их с нуля:
Другие профессии и курсыПРОФЕССИИ
КУРСЫ