Это вторая часть цикла статей про разбор сетевого трафика мобильной MMORPG. Примерные темы цикла:
- Разбор формата сообщений между сервером и клиентом.
- Написание прослушивающего приложения для просмотра трафика игры в удобном виде.
- Перехват трафика и его модификация при помощи не-HTTP прокси-сервера.
- Первые шаги к собственному («пиратскому») серверу.
В этой части я опишу создание прослушивающего приложения (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, чтобы получить все значения кодов.
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();
}
}
Вот и все. Следующим шагом будет перенаправление трафика игры на наш прокси-сервер с целью инъекции, подмены и вырезки пакетов.
Автор: lOlbas