- Разбор формата сообщений между сервером и клиентом.
- Написание прослушивающего приложения для просмотр трафика игры в удобном виде.
- Перехват трафика и его модификация при помощи не-HTTP прокси-сервера.
- Первые шаги к собственному («пиратскому») серверу.
В данной статье я рассмотрю разбор формата сообщений между сервером и клиентом. Заинтересовавшихся прошу под кат.
Требуемые инструменты
Для возможности повторения шагов, описанных ниже, потребуются:
- ПК (я делал на Windows 7/10, но MacOS тоже может подойти, если пункты ниже там доступны);
- Wireshark для анализа пакетов;
- 010Editor для парсинга пакетов по шаблону (не обязательно, но позволяет быстро и легко описывать формат сообщений);
- само мобильное устройство с игрой.
Дополнительно очень желательно наличие под руками данных из игры в читабельном виде, такие как список предметов, существ и др. с их идентификаторами. Это значительно упрощает поиск ключевых моментов в пакетах, а порой позволяет отфильтровать нужное сообщение в постоянном потоке данных.
Разбор формата сообщений между сервером и клиентом
Для начала, нам необходимо видеть трафик мобильного устройства. Сделать это достаточно просто (хотя я очень долго доходил до этого очевидного решения): на нашем ПК создаем точку доступа Wi-Fi, подключаемся к ней с мобильного устройства, выбираем в Wireshark нужный интерфейс — и весь мобильный трафик у нас перед глазами.
Зайдя в игру и подождав некоторое время, чтобы запросы, не связанные с самим игровым сервером, прекратились, можно наблюдать следующую картину:
На данном этапе мы уже можем использовать фильтры Wireshark, чтобы видеть только пакеты между игрой и сервером, а также только с полезной нагрузкой:
tcp && tcp.payload && tcp.port == 44325
Если встать в тихом месте, вдали от других игроков и NPC, и ничего не делать, можно увидеть постоянно повторяющиеся сообщения от сервера и клиента (размером 76 и 84 байт соответственно). В моем случае минимальное количество разнообразных пакетов посылалось на экране выбора персонажа.
Периодичность запроса от клиента очень похожа на пингование. Возьмем несколько сообщений для проверки (3 группы, сверху — запрос с клиента, под ним — ответ сервера):
Первым делом бросается в глаза идентичность пакетов. 8 дополнительных байт у ответа при переводе в десятичную систему очень похожи на метку времени в секундах:
5CD008F816 = 155713765610
(из первой пары). Сверяем часы — да, так и есть. Предыдущие 4 байта совпадают с последними 4 байтами в запросе. При переводе получаем: A4BB16 = 4217110
, что также очень похоже на время, но уже в милисекундах. Оно примерно совпадает со временем с момента запуска игры, и скорее всего так и есть.Осталось рассмотреть первые 6 байт запроса и ответа. Легко заметить зависимость значения первых четырех байт сообщения (назовем этот параметр
L
) от размера сообщения: ответ от сервера больше на 8 байт, значение L
тоже увеличилось на 8, однако размер пакета больше на 6 байт значения L
в обоих случаях. Также можно заметить что два байта после L
сохраняют свое значение как в запросах от клиента, так и от сервера, а учитывая, что их значение отличается на один, можно с уверенностью сказать, что это код сообщения C
(связанные коды сообщений скорее всего будут определены последовательно). Общая структура понятна достаточно, чтобы написать минимальный шаблон для 010Editor:- первые 4 байта —
L
— размер полезной нагрузки сообщения; - следующие 2 байта —
C
— код сообщения; - сама полезная нагрузка.
struct Event {
uint payload_length <bgcolor=0xFFFF00, name="Payload Length">;
ushort event_code <bgcolor=0xFF9988, name="Event Code">;
byte payload[payload_length] <name="Event Payload">;
};
Значит, формат сообщения пинга клиента: послать локальное время пинга; формат ответа сервера: послать то же время и время отправки ответа в секундах. Вроде не сложно, да?
Попробуем разобрать пример посложнее. Стоя в тихом месте и спрятав пакеты пинга, можно найти сообщения телепорта и создания предмета (craft). Начнем с первого. Владея данными игры я знал какое значение точки телепорта искать. Для тестов я использовал точки со значениями
0x2B
, 0x67
, 0x6B
и 0x1AF
. Сравним со значениями в сообщениях: 0x2B
, 0x67
, 0x6B
и 0x3AF
:Непорядок. Видны две проблемы:
- значения не 4-х байтовые, а разного размера;
- не все значения совпадают с данными из файлов, причем в данном случае разница равна 128.
Дополнительно, сравнивая с форматом пинга можно заметить некоторую разницу:
- непонятное
0x08
перед ожидаемым значением; - 4-х байтовое значение, на 4 меньшее
L
(назовем егоD
. Это поле появляется далеко не во всех сообщениях, что немного странно, но там, где оно есть, зависимостьL - 4 = D
сохраняется. С одной стороны, для сообщений с простой структурой (как пинг) оно не требуется, но с другой — выглядит оно бесполезным).
Некоторые из вас, думаю, уже могли догадаться про причину несовпадения ожидамых значений, но я продолжу. Посмотрим что творится в крафте:
Ожидаемые значения 14183 и 14285 тоже не соответствуют действительным 28391 и 28621, но разница тут уже намного больше 128. Проведя много тестов (в том числе и с другими типами сообщений) выяснилось, что чем больше ожидаемое число, тем больше разница между значением в пакете. Что было странно, так это то, что значения до 128 оставались сами собой. Поняли, в чем дело? Очевидная ситуация для тех, кто уже сталкивался с этим, а мне, по незнанию, пришлось два дня разбирать этот «шифр» (в конечном итоге во «взломе» помог анализ значений в бинарном виде). Описанное выше поведение называется Variable Length Quantity (значение переменной длины) — представление числа, в котором используется неопределенное количество байт, где восьмой бит байта (бит продолжения) определяет наличие следующего байта. Из описания очевидно, что чтение VLQ возможно только в порядке Little-Endian. По совпадению все значения в пакетах в таком порядке.
Теперь, когда мы знаем как получить исходное значение, можно написать шаблон для типа:
struct VLQ {
local char size = 1;
while(true) {
byte obf_byte;
if ((obf_byte & 0x80) == 0x80) {
size++;
} else {
break;
}
}
FSeek(FTell() - size);
byte bytes[size];
local uint64 _ = FromVLQ(bytes, size);
};
И функцию преобразования массива байтов в целочисленное значение:
uint64 FromVLQ(byte bytes[], char size) {
local uint64 source = 0;
local int i = 0;
local byte x;
for (i = 0; i < size; i++) {
x = bytes[i];
source |= (x & 0x7F) * Pow(2, i * 7); // Бинарный сдвиг << здесь не работает, т.к. он возможен только для значений, меньше uint32, в то время как нам надо получить uint64
if ((x & 0x80) != 0x80) {
break;
}
}
return source;
};
Но вернемся к созданию предмета. Опять появляется
D
и снова 0x08
перед меняющимся значением. Последние два байта сообщения 0x10 0x01
подозрительно похожи на количество предметов крафта, где 0x10
имеет роль, схожую с 0x08
, но по-прежнему непонятную. Зато теперь можно написать шаблон для этого события:struct CraftEvent {
uint data_length <bgcolor=0x00FF00, name="Data Length">;
byte marker1;
VLQ craft_id <bgcolor=0x00FF00, name="Craft ID">;
byte marker2;
VLQ quantity <bgcolor=0x00FF00, name="Craft Quantity">;
};
Который будет выглядеть вот так:
И все равно это были простые примеры. Посложнее будет разобрать событие движения персонажа. Какую информацию мы ожидаем увидеть? Как минимум координаты персонажа, куда он смотрит, скорость движения и состояние (стоит, бежит, прыгает и т.д.). Так как строк в сообщении не видно, состояние, скорее всего, описывается через
enum
. Путем перебора вариантов, попутно сравнивая их с данными из файлов игры, а также через множество тестов, можно найти три XYZ вектора при помощи вот такого громоздкого шаблона:struct MoveEvent {
uint data_length <bgcolor=0x00FF00, name="Data Length">;
byte marker;
VLQ move_time <bgcolor=0x00FFFF>;
FSkip(2);
byte marker;
float position_x <bgcolor=0x00FF00>;
byte marker;
float position_y <bgcolor=0x00FF00>;
byte marker;
float position_z <bgcolor=0x00FF00>;
FSkip(2);
byte marker;
float direction_x <bgcolor=0x00FFFF>;
byte marker;
float direction_y <bgcolor=0x00FFFF>;
byte marker;
float direction_z <bgcolor=0x00FFFF>;
FSkip(2);
byte marker;
float speed_x <bgcolor=0x00FFFF>;
byte marker;
float speed_y <bgcolor=0x00FFFF>;
byte marker;
float speed_z <bgcolor=0x00FFFF>;
byte marker;
VLQ character_state <bgcolor=0x00FF00>;
};
Наглядный результат:
Зеленая тройка оказалась координатами местоположения, желтые тройки, скорее всего, показывают куда смотрит персонаж и вектор его скорости, а последнее одиночное — состояние персонажа. Можно заметить постоянные байты (маркеры) между значениями координат (
0x0D
перед значением X
, 0x015
перед Y
и 0x1D
перед Z
) и перед состоянием (0x30
), которые подозрительно похожи по смыслу на 0x08
и 0x10
. Проанализировав много маркеров из других событий оказалось, что он определяет тип следующего за ним значения (первыми тремя битами) и семантическмй смысл, т.е. в примере выше если поменять местами вектора, сохранив при этом их маркеры (0x120F
перед координатами и т.д.), игра (теоретически) должна нормально распарсить сообщение. С учетом этой информации, можно добавить пару новых типов:struct Packed {
VLQ marker <bgcolor=0xFFBB00>; // Маркер тоже оказался VLQ!
local uint size = marker.size; // Некоторые сообщения не содержат значения смещения (в списках, например) и там приходится использовать вот такой вычисленный размер структуры
switch (marker._ & 0x7) {
case 1: double v; size += 8; break; // Из анализа других событий
case 5: float v; size += 4; break;
default: VLQ v; size += v.size; break;
}
};
struct PackedVector3 {
Packed marker <name="Marker">;
Packed x <name="X">;
Packed y <name="Y">;
Packed z <name="Z">;
};
Теперь наш шаблон сообщения движения значительно сократился:
struct MoveEvent {
uint data_length <bgcolor=0x00FF00, name="Data Length">;
Packed move_time <bgcolor=0x00FFFF>;
PackedVector3 position <bgcolor=0x00FF00>;
PackedVector3 direction <bgcolor=0x00FF00>;
PackedVector3 speed <bgcolor=0x00FF00>;
Packed state <bgcolor=0x00FF00>;
};
Еще один тип, который может нам понадобиться в следующей статье, это строки, которым предшествует
Packed
-значение их размера:struct PackedString {
Packed length;
char str[length.v._];
};
Теперь, зная примерный формат сообщений, можно написать свое прослушивающее приложение для удобства фильтрации и анализа сообщений, но это уже тема для следующей статьи.
Upd: спасибо aml за подсказку, что описанная выше структура сообщений является Protocol Buffer, а также Tatikoma за ссылку на полезную соответствующую статью.
Комментарии (17)
aml
13.05.2019 17:11+1Внутренности пакетов подозрительно напоминают формат protobuf — https://developers.google.com/protocol-buffers/docs/encoding. 0x08 обозначает "поле с идентификатором 1 типа varint", дальше следует собственно varint, как вы его расшифровали. Потом следующее поле, и следующее и т.д.
lOlbas Автор
13.05.2019 17:18А ведь не додумался погуглить это, хотя даже не знаю какими ключевыми словами искал бы. Спасибо большое! При возможности обновлю статью с указанием этого.
Tatikoma
13.05.2019 18:24+2habr.com/ru/post/321790
Вот сюда загляните, сильно упростит жизнь. Через protodec сразу получите .proto файл, поправите имена полей для читабельности, подключите библиотеку protobuf к своему приложению и жизнь станет прекрасна.
Обновлять статью скорее всего смысла нет, — тут половину статьи менять придётся. Вы говорили про вторую часть, — оно туда отлично подойдёт.
Tarik02
14.05.2019 09:16Я тоже делал подобное с Clash Royale, но только тогда было где подсмотреть и скопировать, но много пакетов доводилось разбирать самому. Там было шифрование (libsodium), во время игры соединение вообще перебрасывались на UDP, но использовался обычный TCP, если первый не смог. А чтобы подключится, вообще надо было изменить APK игры (подсунуть свой адрес сервера, который кстати должен быть фиксированной длины и свой публичный ключ). Потом они изменили протокол (номера пакетов) и шифрование и уже не хотелось продолжать работу над проектом.
Вообще, против ботов легко бороться — ввести так называемую "чексумму", которая может быть посчитана как на сервере, так и на клиенте, клиент должен отсылать её серверу, если не совпадает — гнать клиента подальше.aml
14.05.2019 16:10Если бы всё так легко было… Ничто же не заставляет хакнутый клиент считать контрольную сумму хакнутого клиента — он может исполнять одни файлы, а контрольные суммы считать от оригинальных.
Tarik02
14.05.2019 21:56Контрольная сумма, зависящая от состояния мира. Я говорю про бот, поэтому там будет не хакнутый клиент, а просто свой клиент. Найти алгоритм подсчёта чексуммы в бинарнике (особенно с обфусцированными символами) не так просто, как кажется (например если мы заюзаем в чексумме детерминированный рандом, синхронизированный с сервером во время соединения, время с начала игры, ещё взять туда позиции игроков и всё это приправить ксором). Конечно, можно вытянуть это из бинаря, но на это уйдет немалое количество времени и сил, при этом ничего не мешает разработчикам изменить этот алгоритм на сервере и обновить клиент.
aml
14.05.2019 23:33Когда я ботами баловался, я никогда не писал полностью свою реализацию клиента. Клиент у меня всегда работал оригинальный, а я его просто патчил, чтобы перехватывать пакеты и от сервера и отправлять свои пакеты в ответ. Любые проверки достоверности симуляции всегда выполнялись оригинальным кодом, а чтобы нельзя было обнаружить внедрение, я перехватывал OpenFile и ещё несколько функций заинжекченной dll, и если игра пыталась открывать свои собственные исполнимые файлы, путь подменялся на каталог с оригиналами, т.е. защита думала, что все файлы в порядке.
Защита от этого есть — сервер должен искать запущенные процессы, проверять таблицу распределения памяти и искать несоответствия, но самое важное, что код для этих проверок должен приходить во время каждой сессии с сервера, и он должен периодически меняться. Это всё постоянная работа команды безопасности, и понятно, что этот подход оправдан, только если вред от хакерства в игре больше зарплаты этих людей, что только в больших проектах происходит.Tarik02
15.05.2019 00:06А если игра на Android? Патчить тоже не все умеют. И в чем смысл такого подхода? Например, я попробую открыть сундук. Чексумму мне взять неоткуда, и даже если смогу достать, то клиент не будет знать, что он открывает.
aml
15.05.2019 00:17Не знаю, я только на Винде это делал. Если я пытаюсь открыть сундук, я найду код в игре, который команду формирует, и вызову его. Нехай сам считает, какие там контрольные суммы куда надо.
Tarik02
14.05.2019 22:07Вообще, в Clash Royale было две контрольные суммы:
- контрольная сумма от клиента во время любых действий в меню (открыть сундук, купить что-то в магазине и т.д.), не представляет проблем при разработке кастомного сервера. Отбивает желание делать кастомный клиент сразу (хотя её потом научились считать и делать ботов. Многих, кто их попробовал заюзать — перебанили).
- контрольная сумма от сервера во время игры. Вообще, во время игры сервер и клиент обмениваются только действиями (только поставить такой юнит в такой позиции, ещё смайлики). Там просто детерминированный локстеп. При этом сервер должен прикрепить чексумму игрового состояния (а там количество живих юнитов, их хп, позиции и это приправлено вроде crc32). Если она не совпала, то клиент останавливает игру и запрашивает у сервера полное состояние игры (в продакшене замечал такое только тогда, когда было плохое соединение, или оно пропадало на несколько секунд).
Tatikoma
Как-то совсем простенько.
Ожидал увидеть криптографический ад или хотя бы сжатие данных, которое сбило бы всех с толку. Сейчас вроде уже нечасто встречаются приложения/игры без шифрования трафика?
Так же ожидал увидеть заворачивание трафика на свой MITM, чтобы трафик расшифровывать… Но если нет шифрования, то и свой MITM не нужен…
Может быть стоило добавить свой диссектор для wireshark?
lOlbas Автор
А в мобильных играх с постоянным обменом данных уже используют шифрование? Мне кажется это бы слишком сказалось на производительности…
Шифрование здесь только SSL на этапе получения информации о серверах для подключения, что будет описано в последней части.
Как по мне, сделать свой инструмент намного удобнее (часть 2), чем писать диссектор.
Tatikoma
Используют. Сколько раз не заглядывал в трафик — почти всегда видел байты с энтропией под 100%. Но случалось и много нулей подряд видеть, — тогда делался mitm + замена нужных байтиков (например вместо поражения в бою отправить победу).
Хотя бы минимально добавить XOR уже отобьёт 99% желающих попортить трафик и совершенно не скажется на производительности (ну если не совсем в лоб сделать). SSL обычно снимается в mitmproxy + ssl kill switch на устройстве, он в принципе интереса не представляет.
Кроме того частой практикой является добавлять контрольную сумму к каждому пакету, но возможно в мобильных приложениях это действительно применяется реже.
Свой инструмент — это хорошо, но и диссектор для wireshark тоже интересно. По уму свой инструмент можно научить использовать диссектор wireshark, — прибить двух зайцев одним махом. Посмотрим, что будет во второй части.
lOlbas Автор
В последней части предполагается, что пользователь далек от всего вот этого вот и у него нет «особого» доступа к устройству (т.е. без Jailbreak-а или root-а), а значит ssl kill switch уже не катит.
khanid
Ну это всё полумеры и от слишком люботыного и умного спасёт плохо. Достаточно одного человека, который расковыряет шифрование на стороне клиента и сможет в поток впихнуть свои данные, а дальше это пойдёт гулять.
Из вариантов вижу только античит by design на стороне сервера, который проверяет корректность приходящих от пользователя команд управления. Всё формируется на сервере, а пользователю отправляется уже результат действий. Конечно, в определённых рамках применимо. Т.к., например, я слабо представляю, как можно озвученное мной выше применить к шутеру.
Но посыл, в общем-то, всё равно остаётся. Нельзя довчерять данным, пришедшим от пользователя. Не важно, шифрованными они шли, или плэинтекстом, т.к. ничто не мешает особо умным вмешиваться не в сам поток трафика, а мождифицировать сам клиент, например.
Другое дело, что разработка серверной части усложняется. Но это уже на совести разработчка.
Tatikoma
Да, разумеется так и должно быть, мне всегда казалось это настолько очевидным, что нет нужды говорить об этом (хотя мне встречалась игра, которая всё хранила на клиенте и пересылала на сервер целиком, т.е. сообщения были не «подобрал голду, добавилось 100 голды», а «подобрал голду, теперь у меня её 10000», там были какие-то проверки на сервере, но не очень жёсткие). Но то что вы говорите — не отменяет шифрование. Даже если вы всё проверяете на сервере, — вы не защищены таким образом от ботов.
А вскрытое шифрование можно в любой момент сменить (при этом оставить поддержку старого, но в клиенте игры требовать обязательного обновления), — всех кто будет на старом шифровании занести в блокнотик и пройтись банхаммером.
khanid
Да, это, пожалуй, разумный довод, чтобы ботоводство не цвело сильно.