Как стать автором
Обновить

Создание прослушивающего приложения для просмотра трафика мобильной MMORPG

Время на прочтение13 мин
Количество просмотров11K
Это вторая часть цикла статей про разбор сетевого трафика мобильной MMORPG. Примерные темы цикла:

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

В этой части я опишу создание прослушивающего приложения (sniffer), который позволит нам фильтровать события по их типу и источнику, выводить информацию о сообщении и выброчно сохранять их для анализа, а также немного залезу в исполняемый файл игры («бинарник»), чтобы найти вспомогательную информацию и добавить поддержку Protocol Buffers в приложение. Заинтересовавшихся прошу под кат.

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


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

  • Wireshark для анализа пакетов;
  • .NET;
  • библиотека PcapDotNet для работы с WinPcap;
  • библиотека protobuf-net для работы с Protocol Buffers.

Написание прослушивающего приложения


Как мы помним из предыдущей статьи, игра общается через TCP протокол, причем в рамках сессии делает это только с одним сервером и на одном порте. Для возможности анализа трафика игры нам нужно выполнить следующие задачи:

  • перехватить пакеты мобильного устройства;
  • отфильтровать пакеты игры;
  • добавить данные очередного пакета в буфер для последующей обработки;
  • доставать события игры из буферов по мере его заполнения.

Эти действия реализованы в классе Sniffer, который использует библиотеку PcapDotNet для перехвата пакетов. В метод Sniff мы передаем IP-адрес адаптера (по факту — это адрес ПК, с которого раздается Wi-Fi для мобильного устройства, внутри этой же сети), IP-адрес мобильного устройства и IP-адрес сервера. По причине непостоянности последних двух (после многомесячного наблюдения за разными платформами и серверами выяснилось, что сервер выбирается из пула ~50 серверов, на каждом и которых еще по 5-7 возможных портов) я передаю лишь первые три октета. Использование данной фильтрации видно в методе IsTargetPacket.

public class Sniffer
{
    private byte[] _data = new byte[4096];
    public bool Active { get; set; } = true;

    private string _adapterIP;
    private string _target;
    private string _server;

    private List<byte> _serverBuffer;
    private List<byte> _clientBuffer;
    
    private LivePacketDevice _device = null;
    private PacketCommunicator _communicator = null;
    
    private Action<Event> _eventCallback = null;
    
    public void Sniff(string ip, string target, string server)
    {
        _adapterIP = ip;
        _target = target;
        _server = server;

        _serverBuffer = new List<byte>();
        _clientBuffer = new List<byte>();

        IList<LivePacketDevice> allDevices = LivePacketDevice.AllLocalMachine;

        for (int i = 0; i != allDevices.Count; ++i)
        {
            LivePacketDevice device = allDevices[i];
            var address = device.Addresses[1].Address + "";
            if (address == "Internet " + _adapterIP)
            {
                _device = device;
            }
        }

        _communicator = _device.Open(65536, PacketDeviceOpenAttributes.Promiscuous, 1000);
        _communicator.SetFilter(_communicator.CreateFilter("ip and tcp"));

        new Thread(() =>
        {
            Thread.CurrentThread.IsBackground = true;
            BeginReceive();
        }).Start();
    }
    
    private void BeginReceive()
    {
        _communicator.ReceivePackets(0, OnReceive);

        do
        {
            PacketCommunicatorReceiveResult result = _communicator.ReceivePacket(out Packet packet);
            switch (result)
            {
                case PacketCommunicatorReceiveResult.Timeout: continue;
                case PacketCommunicatorReceiveResult.Ok: OnReceive(packet); break;
            }
        } while (Active);
    }

    public void AddEventCallback(Action<Event> callback)
    {
        _eventCallback = callback;
    }
    
    private void OnReceive(Packet packet)
    {
        if (Active)
        {
            IpV4Datagram ip = packet.Ethernet.IpV4;

            if (IsTargetPacket(ip))
            {
                try
                {
                    ParseData(ip);
                }
                catch (ObjectDisposedException)
                {
                }
                catch (EndOfStreamException e)
                {
                    Console.WriteLine(e);
                }
                catch (Exception)
                {
                    throw;
                }
            }
        }
    }
    
    private bool IsTargetPacket(IpV4Datagram ip)
    {
        var sourceIp = ip.Source.ToString();
        var destIp = ip.Destination.ToString();

        return (sourceIp != _adapterIP && destIp != _adapterIP) && (
               (sourceIp.StartsWith(_target) && destIp.StartsWith(_server)) ||
               (sourceIp.StartsWith(_server) && destIp.StartsWith(_target))
            );
    }
    
    private void ParseData(IpV4Datagram ip)
    {
        TcpDatagram tcp = ip.Tcp;
        if (tcp.Payload != null && tcp.PayloadLength > 0)
        {
            var payload = ExtractPayload(tcp);
            AddToBuffer(ip, payload);
            ProcessBuffers();
        }
    }
    
    private byte[] ExtractPayload(TcpDatagram tcp)
    {
        int payloadLength = tcp.PayloadLength;
        MemoryStream ms = tcp.Payload.ToMemoryStream();
        byte[] payload = new byte[payloadLength];
        ms.Read(payload, 0, payloadLength);
        return payload;
    }
    
    private void AddToBuffer(IpV4Datagram ip, byte[] payload)
    {
        if (ip.Destination.ToString().StartsWith(_target))
        {
            foreach (var value in payload)
                _serverBuffer.Add(value);
        }
        else
        {
            foreach (var value in payload)
                _clientBuffer.Add(value);
        }
    }
    
    private void ProcessBuffers()
    {
        ProcessBuffer(ref _serverBuffer);
        ProcessBuffer(ref _clientBuffer);
    }
    
    private void ProcessBuffer(ref List<byte> buffer)
    {
        // TODO
    }

    public void Suspend()
    {
        Active = false;
    }

    public void Resume()
    {
        Active = true;
    }
}

Отлично, теперь у нас есть два буфера с данными пакетов от клиента и сервера. Вспоминаем формат событий между игрой и сервером:

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">;
};

Исходя из этого можно создать класс события Event:

public enum EventSource
{
    Client, Server
}

public enum EventTypes : ushort
{
    Movement = 11,
    Ping = 30,
    Pong = 31,
    Teleport = 63,
    EnterDungeon = 217
}

public class Event {
    public uint ID;
    public uint Length { get; protected set; }
    public ushort Type { get; protected set; }
    public uint DataLength { get; protected set; }
    public string EventType { get; protected set; }
    public EventSource Direction { get; protected set; }
    protected byte[] _data;
    
    protected BinaryReader _br = null;
    
    public Event(byte[] data, EventSource direction)
    {
        _data = data;
        _br = new BinaryReader(new MemoryStream(_data));
        Length = _br.ReadUInt32();
        Type = _br.ReadUInt16();
        DataLength = 0;
        EventType = $"Unknown ({Type})";
        if (IsKnown())
        {
            EventType = ((EventTypes)Type).ToString();
        }
        Direction = direction;
    }
    
    public virtual void ParseData()
    {

    }

    public bool IsKnown()
    {
        return Enum.IsDefined(typeof(EventTypes), Type);
    }

    public byte[] GetPayload(bool hasDatLength = true)
    {
        var payloadLength = _data.Length - (hasDatLength ? 10 : 6);

        return new List<byte>(_data).GetRange(hasDatLength ? 10 : 6, payloadLength).ToArray();
    }

    public virtual void Save()
    {
        var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Packets", EventType);
        Directory.CreateDirectory(path);
        File.WriteAllBytes(path + $"/{ID}.dump", _data);
    }

    public override string ToString()
    {
        return $"Type {Type}. Data length: {Length}.";
    }
    
    protected ulong ReadVLQ(bool readFlag = true)
    {
        if (readFlag)
        {
            var flag = _br.ReadByte();
        }
        ulong vlq = 0;
        var i = 0;
        for (i = 0; ; i += 7)
        {
            var x = _br.ReadByte();
            vlq |= (ulong)(x & 0x7F) << i;

            if ((x & 0x80) != 0x80)
            {
                break;
            }
        }

        return vlq;
    }
}

Класс Event будет использоваться как базовый класс для всех событий игры. Вот пример класса для события Ping:

public class Ping : Event
{
    private ulong _pingTime;

    public Ping(byte[] data) : base(data, EventSource.Client)
    {
        EventType = "Ping";
        DataLength = 4;
        _pingTime = _br.ReadUInt32();
    }

    public override string ToString()
    {
        return $"Pinging server at {_pingTime}ms.";
    }
}

Теперь когда у нас есть класс события можно дописать методы в Sniffer:

private void ProcessBuffer(ref List<byte> buffer)
{
    if (buffer.Count > 0)
    {
        while (Active)
        {
            if (buffer.Count > 4) // Первые 4 байта в событии содержат размер полезной нагрузки ...
            {
                var eventLength = BitConverter.ToInt32(buffer.Take(4).ToArray(), 0) + 6; // ... поэтому размер события - это размер П.Н. + первые 4 байта + 2 байта кода события
                if (eventLength >= 6 && buffer.Count >= eventLength)
                {
                    var eventData = buffer.Take(eventLength).ToArray();
                    var ev = CreateEvent(eventData, direction);
                    buffer.RemoveRange(0, eventLength);

                    continue;
                }
            }

            break;
        }
    }
}

private Event CreateEvent(byte[] data, EventSource direction)
{
    var ev = new Event(data, direction);
    var eventType = Enum.GetName(typeof(EventTypes), ev.Type);

    if (eventType != null)
    {
        try
        {
            // Создаем экземпляр класса события (например, <code>Ping</code>).
            var className = "Events." + eventType;
            Type t = Type.GetType(className);
            ev = (Event)Activator.CreateInstance(t, data);
        }
        catch (Exception)
        {
            // Если специального класса нет - создаем экземпляр базового.
            ev = new Event(data, direction);
        }
        finally
        {

        }
    }
    
    _eventCallback?.Invoke(ev);

    return ev;
}

Создадим класс формы, который будет запускать прослушку:

public partial class MainForm : Form
{
    private Sniffer _sniffer = null;
    private List<Event> _events = new List<Event>();

    private List<ushort> _eventTypesFilter = new List<ushort>();
    private bool _showClientEvents = true;
    private bool _showServerEvents = true;
    private bool _showUnknownEvents = false;
    private bool _clearLogsOnRestart = true;
    private uint _eventId = 1;
    
    private void InitializeSniffer()
    {
        _sniffer = new Sniffer();
        _sniffer.AddEventCallback(NewEventThreaded);
        _sniffer.Sniff("192.168.137.1", "192.168.137.", "123.45.67.");
    }
    
    private void NewEventThreaded(Event ev)
    {
        events_table.Invoke(new NewEventCallback(NewEvent), ev);
    }

    public delegate void NewEventCallback(Event ev);

    private void NewEvent(Event ev)
    {
        ev.ID = _eventId++;
        _events.Add(ev);
        LogEvent(ev);
    }

    private void LogEvent(Event ev)
    {
        if (FilterEvent(ev))
        {
            var type = ev.GetType();
            events_table.Rows.Add(1);
            events_table.Rows[events_table.RowCount - 1].Cells[0].Value = ev.ID;
            events_table.Rows[events_table.RowCount - 1].Cells[1].Value = ev.EventType;
            events_table.Rows[events_table.RowCount - 1].Cells[2].Value = Enum.GetName(typeof(EventSource), ev.Direction);
            events_table.Rows[events_table.RowCount - 1].Cells[3].Value = ev.ToString();
        }
    }
    
    private void ReloadEvents()
    {
        events_table.Rows.Clear();
        events_table.Refresh();

        foreach (var ev in _events)
        {
            LogEvent(ev);
        }
    }

    private bool FilterEvent(Event ev)
    {
        return (
                (ev.Direction == EventSource.Client && _showClientEvents) ||
                (ev.Direction == EventSource.Server && _showServerEvents)
               ) && (_eventTypesFilter.Contains(ev.Type) || (!ev.IsKnown() && _showUnknownEvents));
    }
}

Готово! Теперь можно добавить пару таблиц для управления списком событий (через нее заполняется _eventTypesFilter) и просмотра в реальном времени (главная таблица events_table). Например, я фильтровал по следующим критериям (метод FilterEvent):

  • показ событий от клиента;
  • показ событий от сервера;
  • показ неизвестных событий;
  • показ выбранных известных событий.

Изучаем исполняемый файл игры


Хотя теперь можно без проблем анализировать события игры, предстоит огромная ручная работа по определению не только смысла всех кодов событий, но и структуры полезной нагрузки, что будет достаточно сложно, особенно если она изменяется в зависимости от некоторых полей. Я решил поискать какую-нибудь информацию в исполняемом файле игры. Так как игра кроссплатформенная (доступна на Windows, iOS и Android), то доступны следующие варианты для анализа:

  • .exe файл (у меня расположен по пути C:/Program Files/WindowsApps/%appname%/;
  • бинарный файл iOS, который шифруется Apple, но имея JailBreak можно получить расшифрованную версию при помощи Crackulous или подобных;
  • динамическая библиотека (shared object) Android. Находится по пути /data/data/%app-vendor-name%/lib/.

Не имея понятия какую архитектуру выбирать для Android и iOS, я начал с .exe файла. Загружаем бинарник в IDA, видим выбор архитектур.



Цель нашего поиска — какие-нибудь очень полезные строки, а значит декомпиляция ассемблера не входит в планы, но на всякий случай выбираем «executable 80386», так как варианты «Binary File» и «MS-DOS executable» явно не подходят. Жмем «OK», ждем пока файл загрузится в базу данных, и желательно дождаться окончания анализа файла. Окончание анализа можно узнать по тому, что в статус баре внизу слева будет следующее состояние:



Переходим на вкладку Strings (View/Open subviews/Strings или Shift + F12). Процесс генерации строк может занять некоторое время. В моем случае было найдено ~47к строк. Адреса расположения строк имеют префикс вида .data, .rdata и другие. В моем случае, все «интересные» строки находились в секции .rdata, размер которой был ~44.5к записей. Просматривая таблицу можно увидеть:

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

Наконец, ближе к концу таблицы попадается то, что мы искали.



Это список кодов событий между клиентом и сервером. Это может упростить нам жизнь при разборе сетевого протокола игры. Но не будем останавливаться на достигнутом! Нужно проверить можно ли как-то получить числовое значение кода события. Видим «знакомые» из предыдущей статьи коды CMSG_PING и SMSG_PONG, имеющих коды 30 (1E16) и 31 (1F16) соответственно. Двойным щелчком по строке переходим на это место в коде.



Действительно, сразу за строковыми значениями кодов идет последовательность 0x10 0x1E и 0x10 0x1F. Отлично, значит, можно распарсить всю таблицу и получить список событий и их числовое значение, что еще больше упростит разбор протокола.

К сожалению, Windows-версия игры отстает от мобильных версий на очень много версий, а потому информация из .exe не является актуальной, и хотя она может помочь, всецело полагаться на нее не стоит. Следующим я решил изучить динамическую библиотеку с Android, так как на одном форуме видел, что там, в отличие от бинарников iOS, содержится много мета-информации о классах. Но увы, поиск по файлу значений CMSG_PING не дал результатов.

Без надежды делаю тот же поиск в бинарнике iOS — невероятно, но там оказались те же данные, что и в .exe! Загружаем файл в IDA.



Выбираю первый предложенный вариант, так как не уверен какой надо. Снова ждем окончания анализа файла (бинарник в почти 4 раза больше по размеру .exe, время анализа, естественно, тоже увеличилось). Открываем окно со строками, которых на сей раз оказалось 51к. Через Ctrl + F ищем CMSG_PING и… не находим. Вводя код посимвольно, можно заметить вот такой результат:



Почему-то IDA скомпоновала весь объект Opcode.proto в одну строку. Двойным щелчком переходим на это место в коде и видим, что структура описана так же, как и в .exe файле, значит можно вырезать ее и сконвертировать в Enum.

Тут наконец стоит вспомнить, как в комментариях к прошлой статье aml подсказал, что структура сообщений игры является реализацией Protocol Buffers. Если внимательно присмотреться к коду в бинарном файле, можно увидеть, что описание Opcode тоже в этом формате.



Напишем шаблон-парсер для 010Editor, чтобы получить все значения кодов.

Обновленный код типов Packed* для 010Editor
Небольшие изменения в типах содержат проверку метки поля, чтобы пропускать отсутствующие.

uint PeekTag() {
    if (FTell() == FileSize()) {
        return 0;
    }
    
    Varint tag;
    FSkip(-tag.size);
    
    return tag._ >> 3;
}

struct Packed (uint fieldNumber) {
    if (PeekTag() != fieldNumber) {
        break;
    }
    
    Varint key <bgcolor=0xFFBB00>;
    
    local uint wiredType = key._ & 0x7;
    local uint field = key._ >> 3;
    local uint size = key.size;
    
    switch (wiredType) {
        case 1: double value; size += 8; break;
        case 5: float value; size += 4; break;
        default: Varint value; size += value.size; break;
    }
};

struct PackedString(uint fieldNumber) {
    if (PeekTag() != fieldNumber) {
        break;
    }
    
    Packed length(fieldNumber);
    char str[length.value._];
};


struct Code {
    Packed size(2) <bgcolor=0x00FF00>;
    PackedString code_name(1) <bgcolor=0x00FF00>;
    Packed code_value(2) <bgcolor=0x00FF00>;
    Printf("%s = %d,\n", code_name.str, code_value.value._); // Вывод значения в консоль для вставки в Enum
};

struct Property {
    Packed size(5) <bgcolor=0x00FF00>;
    
    PackedString prop_name(1) <bgcolor=0x00FF00>;
    while (FTell() - 0x176526B - prop_name.length.value._ < size.value._) {
        Code codes <name="Codes">;
    }
};

struct {
    FSkip(0x176526B);
    
    PackedString object(1) <bgcolor=0x00FF00>;
    PackedString format(2) <bgcolor=0x00FFFF>;
    Property prop;
} file;

В результате получаем что-то подобное:



Дальше интереснее! Заметили pb в описании объекта? Надо бы поискать другие строки, вдруг таких объектов еще много?



Результаты крайне неожиданные. Судя по всему, в исполняемом файле игры описаны многие типы данных, включая перечисления и форматы сообщений между сервером и клиентом. Вот пример описания типа, описывающего положение объекта в мире:



Быстрый поиск выявил два больших места с описаниями типов, хотя при более тщательном изучении наверняка выявятся и другие мелкие места. Вырезав их, я написал маленький скрипт на C#, чтобы разделить описания по файлам (по структуре это схоже с описанием списка кодов событий) — так проще анализировать их в 010Editor.

class Program
{
    static void Main(string[] args)
    {
        var br = new BinaryReader(new FileStream("./BinaryFile.partX", FileMode.Open));

        while (br.BaseStream.Position < br.BaseStream.Length)
        {
            var startOffset = br.BaseStream.Position;

            var length = ReadVLQ(br, out int size);
            var tag = br.ReadByte();
            var eventName = br.ReadString();

            br.BaseStream.Position = startOffset;

            File.WriteAllBytes($"./parsed/{eventName}", br.ReadBytes((int)length + size + 1));
        }
    }
    
    static ulong ReadVLQ(BinaryReader br, out int size)
    {
        var flag = br.ReadByte();
        ulong vlq = 0;
        size = 0;
        var i = 0;
        for (i = 0; ; i += 7)
        {
            var x = br.ReadByte();
            vlq |= (ulong)(x & 0x7F) << i;
            size++;

            if ((x & 0x80) != 0x80)
            {
                break;
            }
        }

        return vlq;
    }
}

Подробно разбирать формат описания структур не буду, т.к. либо он специфичен для рассматриваемой игры, либо это общепринятый в Protocol Buffers формат (если кто знает наверняка, укажите, пожалуйста, в комментариях). Из того, что смог обнаружить:

  • описание также идет в формате Protocol Buffers;
  • описание каждого поля содержит его имя, номер и тип данных, для которой использовалась своя таблица типов:
    string TypeToStr (uint type) {
        switch (type) {
            case 2: return "Float";
            case 4: return "UInt64";
            case 5: return "UInt32";
            case 8: return "Boolean";
            case 9: return "String";
            case 11: return "Struct";
            case 14: return "Enum";
            default: local string s; SPrintf(s, "%Lu", type); return s;
        }
    };
    
  • если тип данных — перечисление или структура, то далее шла ссылка на нужный объект.

Ну и последнее, что нам осталось, — это использовать полученную информацию в нашем прослушивающем приложении: парсить сообщения при помощи библиотеки protobuf-net. Подключите библиотеку через NuGet, добавьте using ProtoBuf; и можно создавать классы для описания сообщений. Возьмем один из примеров из прошлой статьи: движение персонажа. Распаршеное описание формата при подсветке сегментов выглядит примерно так:



Отладочный вывод позволяет создать из этого краткое описание:

Field 1 (Type 13): time 
Field 2 (Struct .pb.CxGS_Vec3): position 
Field 3 (UInt64): guid 
Field 4 (Struct .pb.CxGS_Vec3): direction 
Field 5 (Struct .pb.CxGS_Vec3): speed 
Field 6 (UInt32): state 
Field 10 (UInt32): flag 
Field 11 (Float): y_speed 
Field 12 (Boolean): is_flying 
Field 7 (UInt32): emote_id 
Field 9 (UInt32): emote_duration 
Field 8 (Boolean): emote_loop 

Теперь можно создать соответствующий класс с помощью библиотеки protobuf-net.

[ProtoContract]
public class MoveInfo : ProtoBufEvent<MoveInfo>
{
    [ProtoMember(3)]
    public ulong GUID;

    [ProtoMember(1)]
    public ulong Time;

    [ProtoMember(2)]
    public Vec3 Position;

    [ProtoMember(4)]
    public Vec3 Direction;

    [ProtoMember(5)]
    public Vec3 Speed;

    [ProtoMember(6)]
    public ulong State;

    [ProtoMember(7, IsRequired = false)]
    public uint EmoteID;

    [ProtoMember(8, IsRequired = false)]
    public bool EmoteLoop;

    [ProtoMember(9, IsRequired = false)]
    public uint EmoteDuration;

    [ProtoMember(10, IsRequired = false)]
    public uint Flag;

    [ProtoMember(11, IsRequired = false)]
    public float SpeedY;

    [ProtoMember(12)]
    public bool IsFlying;

    public override string ToString()
    {
        return $"{GUID}: {Position}";
    }
}

Для сравнения, вот шаблон того же события из прошлой статьи:

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>;
};

При наследовании класса Event мы можем переопределить метод ParseData, десериализируя данные пакета:

class CMSG_MOVE_INFO : Event
{
    private MoveInfo _message;

    [...]
    
    public override void ParseData()
    {
        _message = MoveInfo.Deserialize(GetPayload());
    }

    public override string ToString()
    {
        return _message.ToString();
    }
}

Вот и все. Следующим шагом будет перенаправление трафика игры на наш прокси-сервер с целью инъекции, подмены и вырезки пакетов.
Теги:
Хабы:
+17
Комментарии1

Публикации

Изменить настройки темы

Истории

Работа

.NET разработчик
72 вакансии

Ближайшие события