За годы игры в одну мобильную ММОRPG у меня накопился некоторый опыт по ее реверс-инжинирингу, которым я хотел бы поделиться в цикле статей. Примерные темы:
- Разбор формата сообщений между сервером и клиентом.
- Написание прослушивающего приложения для просмотр трафика игры в удобном виде.
- Перехват трафика и его модификация при помощи не-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._];
};
Теперь, зная примерный формат сообщений, можно написать свое прослушивающее приложение для удобства фильтрации и анализа сообщений, но это уже тема для следующей статьи.
Автор: lOlbas