Хабрахабр

[Из песочницы] Как разобрать сетевой протокол мобильной MMORPG

За годы игры в одну мобильную ММОRPG у меня накопился некоторый опыт по ее реверс-инжинирингу, которым я хотел бы поделиться в цикле статей. Примерные темы:

  1. Разбор формата сообщений между сервером и клиентом.
  2. Написание прослушивающего приложения для просмотр трафика игры в удобном виде.
  3. Перехват трафика и его модификация при помощи не-HTTP прокси-сервера.
  4. Первые шаги к собственному («пиратскому») серверу

В данной статье я рассмотрю разбор формата сообщений между сервером и клиентом. Заинтересовавшихся прошу под кат.

Требуемые инструменты

Для возможности повторения шагов, описанных ниже, потребуются:

  • ПК (я делал на 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, что также очень похоже на время, но уже в милисекундах. Оно примерно совпадает со временем с момента запуска игры, и скорее всего так и есть.

Легко заметить зависимость значения первых четырех байт сообщения (назовем этот параметр L) от размера сообщения: ответ от сервера больше на 8 байт, значение L тоже увеличилось на 8, однако размер пакета больше на 6 байт значения L в обоих случаях. Осталось рассмотреть первые 6 байт запроса и ответа. Общая структура понятна достаточно, чтобы написать минимальный шаблон для 010Editor: Также можно заметить что два байта после L сохраняют свое значение как в запросах от клиента, так и от сервера, а учитывая, что их значение отличается на один, можно с уверенностью сказать, что это код сообщения C (связанные коды сообщений скорее всего будут определены последовательно).

  • первые 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 и 0x3AF: Для тестов я использовал точки со значениями 0x2B, 0x67, 0x6B и 0x1AF.

Непорядок. Видны две проблемы:

  1. значения не 4-х байтовые, а разного размера;
  2. не все значения совпадают с данными из файлов, причем в данном случае разница равна 128.

Дополнительно, сравнивая с форматом пинга можно заметить некоторую разницу:

  • непонятное 0x08 перед ожидаемым значением;
  • 4-х байтовое значение, на 4 меньшее L (назовем его D. Это поле появляется далеко не во всех сообщениях, что немного странно, но там, где оно есть, зависимость L - 4 = D сохраняется. С одной стороны, для сообщений с простой структурой (как пинг) оно не требуется, но с другой — выглядит оно бесполезным).

Некоторые из вас, думаю, уже могли догадаться про причину несовпадения ожидамых значений, но я продолжу. Посмотрим что творится в крафте:

Ожидаемые значения 14183 и 14285 тоже не соответствуют действительным 28391 и 28621, но разница тут уже намного больше 128. Проведя много тестов (в том числе и с другими типами сообщений) выяснилось, что чем больше ожидаемое число, тем больше разница между значением в пакете. Что было странно, так это то, что значения до 128 оставались сами собой. Поняли, в чем дело? Очевидная ситуация для тех, кто уже сталкивался с этим, а мне, по незнанию, пришлось два дня разбирать этот «шифр» (в конечном итоге во «взломе» помог анализ значений в бинарном виде). Описанное выше поведение называется Variable Length Quantity (значение переменной длины) — представление числа, в котором используется неопределенное количество байт, где восьмой бит байта (бит продолжения) определяет наличие следующего байта. Из описания очевидно, что чтение VLQ возможно только в порядке Little-Endian. По совпадению все значения в пакетах в таком порядке.

Теперь, когда мы знаем как получить исходное значение, можно написать шаблон для типа:

struct VLQ 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._];
};

Теперь, зная примерный формат сообщений, можно написать свое прослушивающее приложение для удобства фильтрации и анализа сообщений, но это уже тема для следующей статьи.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть