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

Средняя зарплата в IT

110 000 ₽/мес.
Средняя зарплата по всем IT-специализациям на основании 8 550 анкет, за 2-ое пол. 2020 года Узнать свою зарплату
Реклама
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее

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

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое