Game development
Reverse engineering
May 13

Как разобрать сетевой протокол мобильной MMORPG

From Sandbox
За годы игры в одну мобильную ММО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, что также очень похоже на время, но уже в милисекундах. Оно примерно совпадает со временем с момента запуска игры, и скорее всего так и есть.

Осталось рассмотреть первые 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:


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

  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 {
    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 за ссылку на полезную соответствующую статью.

+26
8.9k 83
Comments 17
Top of the day