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

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

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 17

    +1
    Как-то совсем простенько.
    Ожидал увидеть криптографический ад или хотя бы сжатие данных, которое сбило бы всех с толку. Сейчас вроде уже нечасто встречаются приложения/игры без шифрования трафика?
    Так же ожидал увидеть заворачивание трафика на свой MITM, чтобы трафик расшифровывать… Но если нет шифрования, то и свой MITM не нужен…
    Может быть стоило добавить свой диссектор для wireshark?
      0
      А в мобильных играх с постоянным обменом данных уже используют шифрование? Мне кажется это бы слишком сказалось на производительности…

      Шифрование здесь только SSL на этапе получения информации о серверах для подключения, что будет описано в последней части.

      Как по мне, сделать свой инструмент намного удобнее (часть 2), чем писать диссектор.
        0
        Используют. Сколько раз не заглядывал в трафик — почти всегда видел байты с энтропией под 100%. Но случалось и много нулей подряд видеть, — тогда делался mitm + замена нужных байтиков (например вместо поражения в бою отправить победу).

        Хотя бы минимально добавить XOR уже отобьёт 99% желающих попортить трафик и совершенно не скажется на производительности (ну если не совсем в лоб сделать). SSL обычно снимается в mitmproxy + ssl kill switch на устройстве, он в принципе интереса не представляет.
        Кроме того частой практикой является добавлять контрольную сумму к каждому пакету, но возможно в мобильных приложениях это действительно применяется реже.

        Свой инструмент — это хорошо, но и диссектор для wireshark тоже интересно. По уму свой инструмент можно научить использовать диссектор wireshark, — прибить двух зайцев одним махом. Посмотрим, что будет во второй части.
          0
          В последней части предполагается, что пользователь далек от всего вот этого вот и у него нет «особого» доступа к устройству (т.е. без Jailbreak-а или root-а), а значит ssl kill switch уже не катит.
            +1
            Ну это всё полумеры и от слишком люботыного и умного спасёт плохо. Достаточно одного человека, который расковыряет шифрование на стороне клиента и сможет в поток впихнуть свои данные, а дальше это пойдёт гулять.
            Из вариантов вижу только античит by design на стороне сервера, который проверяет корректность приходящих от пользователя команд управления. Всё формируется на сервере, а пользователю отправляется уже результат действий. Конечно, в определённых рамках применимо. Т.к., например, я слабо представляю, как можно озвученное мной выше применить к шутеру.
            Но посыл, в общем-то, всё равно остаётся. Нельзя довчерять данным, пришедшим от пользователя. Не важно, шифрованными они шли, или плэинтекстом, т.к. ничто не мешает особо умным вмешиваться не в сам поток трафика, а мождифицировать сам клиент, например.
            Другое дело, что разработка серверной части усложняется. Но это уже на совести разработчка.
              +3
              Да, разумеется так и должно быть, мне всегда казалось это настолько очевидным, что нет нужды говорить об этом (хотя мне встречалась игра, которая всё хранила на клиенте и пересылала на сервер целиком, т.е. сообщения были не «подобрал голду, добавилось 100 голды», а «подобрал голду, теперь у меня её 10000», там были какие-то проверки на сервере, но не очень жёсткие). Но то что вы говорите — не отменяет шифрование. Даже если вы всё проверяете на сервере, — вы не защищены таким образом от ботов.

              А вскрытое шифрование можно в любой момент сменить (при этом оставить поддержку старого, но в клиенте игры требовать обязательного обновления), — всех кто будет на старом шифровании занести в блокнотик и пройтись банхаммером.
                0
                вы не защищены таким образом от ботов

                Да, это, пожалуй, разумный довод, чтобы ботоводство не цвело сильно.
        +2

        Внутренности пакетов подозрительно напоминают формат protobuf — https://developers.google.com/protocol-buffers/docs/encoding. 0x08 обозначает "поле с идентификатором 1 типа varint", дальше следует собственно varint, как вы его расшифровали. Потом следующее поле, и следующее и т.д.

          0
          А ведь не додумался погуглить это, хотя даже не знаю какими ключевыми словами искал бы. Спасибо большое! При возможности обновлю статью с указанием этого.
            +3
            habr.com/ru/post/321790
            Вот сюда загляните, сильно упростит жизнь. Через protodec сразу получите .proto файл, поправите имена полей для читабельности, подключите библиотеку protobuf к своему приложению и жизнь станет прекрасна.

            Обновлять статью скорее всего смысла нет, — тут половину статьи менять придётся. Вы говорили про вторую часть, — оно туда отлично подойдёт.
          0

          Я тоже делал подобное с Clash Royale, но только тогда было где подсмотреть и скопировать, но много пакетов доводилось разбирать самому. Там было шифрование (libsodium), во время игры соединение вообще перебрасывались на UDP, но использовался обычный TCP, если первый не смог. А чтобы подключится, вообще надо было изменить APK игры (подсунуть свой адрес сервера, который кстати должен быть фиксированной длины и свой публичный ключ). Потом они изменили протокол (номера пакетов) и шифрование и уже не хотелось продолжать работу над проектом.
          Вообще, против ботов легко бороться — ввести так называемую "чексумму", которая может быть посчитана как на сервере, так и на клиенте, клиент должен отсылать её серверу, если не совпадает — гнать клиента подальше.

            0
            Если бы всё так легко было… Ничто же не заставляет хакнутый клиент считать контрольную сумму хакнутого клиента — он может исполнять одни файлы, а контрольные суммы считать от оригинальных.
              0

              Контрольная сумма, зависящая от состояния мира. Я говорю про бот, поэтому там будет не хакнутый клиент, а просто свой клиент. Найти алгоритм подсчёта чексуммы в бинарнике (особенно с обфусцированными символами) не так просто, как кажется (например если мы заюзаем в чексумме детерминированный рандом, синхронизированный с сервером во время соединения, время с начала игры, ещё взять туда позиции игроков и всё это приправить ксором). Конечно, можно вытянуть это из бинаря, но на это уйдет немалое количество времени и сил, при этом ничего не мешает разработчикам изменить этот алгоритм на сервере и обновить клиент.

                0
                Когда я ботами баловался, я никогда не писал полностью свою реализацию клиента. Клиент у меня всегда работал оригинальный, а я его просто патчил, чтобы перехватывать пакеты и от сервера и отправлять свои пакеты в ответ. Любые проверки достоверности симуляции всегда выполнялись оригинальным кодом, а чтобы нельзя было обнаружить внедрение, я перехватывал OpenFile и ещё несколько функций заинжекченной dll, и если игра пыталась открывать свои собственные исполнимые файлы, путь подменялся на каталог с оригиналами, т.е. защита думала, что все файлы в порядке.

                Защита от этого есть — сервер должен искать запущенные процессы, проверять таблицу распределения памяти и искать несоответствия, но самое важное, что код для этих проверок должен приходить во время каждой сессии с сервера, и он должен периодически меняться. Это всё постоянная работа команды безопасности, и понятно, что этот подход оправдан, только если вред от хакерства в игре больше зарплаты этих людей, что только в больших проектах происходит.
                  0

                  А если игра на Android? Патчить тоже не все умеют. И в чем смысл такого подхода? Например, я попробую открыть сундук. Чексумму мне взять неоткуда, и даже если смогу достать, то клиент не будет знать, что он открывает.

                    0

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

                0

                Вообще, в Clash Royale было две контрольные суммы:


                • контрольная сумма от клиента во время любых действий в меню (открыть сундук, купить что-то в магазине и т.д.), не представляет проблем при разработке кастомного сервера. Отбивает желание делать кастомный клиент сразу (хотя её потом научились считать и делать ботов. Многих, кто их попробовал заюзать — перебанили).
                • контрольная сумма от сервера во время игры. Вообще, во время игры сервер и клиент обмениваются только действиями (только поставить такой юнит в такой позиции, ещё смайлики). Там просто детерминированный локстеп. При этом сервер должен прикрепить чексумму игрового состояния (а там количество живих юнитов, их хп, позиции и это приправлено вроде crc32). Если она не совпала, то клиент останавливает игру и запрашивает у сервера полное состояние игры (в продакшене замечал такое только тогда, когда было плохое соединение, или оно пропадало на несколько секунд).

            Only users with full accounts can post comments. Log in, please.