Хабрахабр

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

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

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

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

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

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

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

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

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

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

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

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

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

Напишем шаблон-парсер для 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(); }
}

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

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

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

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

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

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