Esta é a segunda parte de uma série de artigos sobre a análise do tráfego de rede do MMORPG móvel. Tópicos de exemplo de loop:
- Analise o formato da mensagem entre servidor e cliente.
- Escrevendo um aplicativo de escuta para visualizar o tráfego do jogo de maneira conveniente.
- Interceptação de tráfego e sua modificação usando um servidor proxy não HTTP.
- Os primeiros passos para o seu próprio servidor ("pirateado").
Nesta parte, descreverei a criação de um aplicativo de escuta (sniffer), que nos permitirá filtrar eventos por tipo e fonte, exibir informações sobre a mensagem e salvá-los seletivamente para análise, além de entrar um pouco no arquivo executável do jogo ("binário") informações de suporte e adicione suporte aos
Buffers de Protocolo ao aplicativo. Interessado, peço gato.
Ferramentas necessárias
Para poder repetir as etapas descritas abaixo, você precisará:
- Wireshark para análise de pacotes;
- .NET
- Biblioteca PcapDotNet para trabalhar com o WinPcap;
- biblioteca protobuf-net para trabalhar com buffers de protocolo.
Escrevendo um aplicativo de escuta
Como lembramos do
artigo anterior , o jogo se comunica através do protocolo TCP e, na estrutura da sessão, ele é feito com apenas um servidor e em uma porta. Para poder analisar o tráfego do jogo, precisamos executar as seguintes tarefas:
- interceptar pacotes de um dispositivo móvel;
- filtrar pacotes de jogos;
- adicione os dados do próximo pacote ao buffer para processamento adicional;
- Receba eventos de jogos de buffers conforme eles são preenchidos.
Essas ações são implementadas na classe
Sniffer
, que usa a biblioteca PcapDotNet para interceptar pacotes. No método
Sniff
, passamos o endereço IP do adaptador (na verdade, esse é o endereço do PC do qual o Wi-Fi é distribuído para o dispositivo móvel na mesma rede), o endereço IP do dispositivo móvel e o endereço IP do servidor. Devido à inconsistência dos dois últimos (depois de meses monitorando plataformas e servidores diferentes, verificou-se que o servidor é selecionado de um pool de ~ 50 servidores, cada um dos quais ainda possui de 5 a 7 portas possíveis), transmito apenas os três primeiros octetos. O uso dessa filtragem é visível no método
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) {
Ótimo, agora temos dois buffers com dados de pacote do cliente e servidor. Lembre-se do formato de eventos entre o jogo e o servidor:
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">; };
Com base nisso, você pode criar uma classe de eventos
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; } }
A classe
Event
será usada como classe base para todos os eventos do jogo. Aqui está um exemplo de classe para o evento
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."; } }
Agora que temos a classe de eventos, podemos adicionar métodos ao
Sniffer
:
private void ProcessBuffer(ref List<byte> buffer) { if (buffer.Count > 0) { while (Active) { if (buffer.Count > 4)
Crie uma classe de formulário que acionará a escuta telefônica:
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)); } }
Feito! Agora você pode adicionar algumas tabelas para gerenciar a lista de eventos (por meio dela,
_eventTypesFilter
preenchida) e visualizar em tempo real (a tabela principal
events_table
). Por exemplo,
FilterEvent
pelos seguintes critérios (método
FilterEvent
):
- mostrar eventos do cliente;
- mostrar eventos do servidor;
- exibição de eventos desconhecidos;
- Mostrar eventos conhecidos selecionados.
Aprendendo o jogo executável
Embora agora seja possível analisar os eventos do jogo sem problemas, há uma enorme quantidade de trabalho manual para determinar não apenas o significado de todos os códigos de eventos, mas também a estrutura da carga útil, o que será bastante difícil, especialmente se ele mudar dependendo de alguns campos. Decidi procurar algumas informações no arquivo executável do jogo. Como o jogo é multiplataforma (disponível no Windows, iOS e Android), as seguintes opções estão disponíveis para análise:
- arquivo .exe (localizei no caminho C: / Arquivos de programas / WindowsApps /% appname% /;
- um arquivo iOS binário criptografado pela Apple, mas com o JailBreak você pode obter a versão descriptografada usando Crackulous ou algo semelhante;
- Biblioteca compartilhada Android Está localizado no caminho / data / data /% app-vendor-name% / lib /.
Não tendo idéia de qual arquitetura escolher para Android e iOS, comecei com um arquivo .exe. Carregamos o binário no IDA, vemos a escolha das arquiteturas.

O objetivo de nossa pesquisa são algumas linhas muito úteis, o que significa que a descompilação do assembler não está incluída nos planos, mas apenas no caso, selecione "executável 80386", pois as opções "Arquivo Binário" e "Executável do MS-DOS" claramente não são adequadas. Clique em "OK", aguarde até o arquivo ser carregado no banco de dados e é aconselhável aguardar até que a análise do arquivo seja concluída. O final da análise pode ser encontrado pelo fato de que na barra de status no canto inferior esquerdo haverá o seguinte estado:

Vá para a guia Strings (Exibir / Abrir subviews / Strings ou
Shift + F12
). O processo de geração de linha pode levar algum tempo. No meu caso, foram encontradas ~ 47k linhas. Os endereços para o local das strings têm um prefixo no formato
.data
,
.rdata
e
outros . No meu caso, todas as linhas "interessantes" estavam na seção
.rdata
, cujo tamanho era de ~ 44,5k registros. Olhando através da tabela, você pode ver:
- mensagens de erro e segmentos de consulta durante a fase de login;
- cadeias de erro e informações de inicialização para o jogo, mecanismo de jogo, interfaces;
- muito lixo;
- uma lista de mesas de jogo no lado do cliente;
- valores usados do mecanismo de jogo no jogo;
- lista de efeitos;
- lista enorme de chaves de localização de interface;
- etc.
Finalmente, mais perto do final da mesa vem o que estávamos procurando.

Esta é uma lista de códigos de eventos entre cliente e servidor. Isso pode simplificar nossas vidas ao analisar o protocolo de rede do jogo. Mas não vamos parar por aí! É necessário verificar se é possível obter de alguma forma o valor numérico do código do evento. Vemos o "familiar" dos códigos do artigo anterior
CMSG_PING
e
SMSG_PONG
, com os códigos 30 (
1E 16
) e 31 (
1F 16
), respectivamente. Clique duas vezes na linha para ir para este local no código.

De fato, imediatamente após os valores da sequência dos códigos há uma sequência de
0x10 0x1E
e
0x10 0x1F
. Bem, isso significa que você pode analisar a tabela inteira e obter uma lista de eventos e seu valor numérico, o que simplificará ainda mais a análise do protocolo.
Infelizmente, a versão para Windows do jogo fica atrás das versões para celular em muitas versões e, portanto, as informações de .exe não são relevantes e, embora possam ajudar, você não deve confiar inteiramente nelas. Em seguida, decidi estudar a biblioteca dinâmica com Android, como vi em um fórum que, ao contrário dos binários do iOS, contém muitas metainformações sobre as classes. Mas, infelizmente, uma pesquisa no
CMSG_PING
valor
CMSG_PING
não retornou resultados.
Sem esperança, estou fazendo a mesma pesquisa no binário do iOS - inacreditável, mas os dados foram os mesmos que em .exe! Faça o upload do arquivo para o IDA.

Eu escolhi a primeira opção proposta, porque não tenho certeza de qual é necessária. Novamente, estamos aguardando o final da análise do arquivo (o binário é quase quatro vezes maior em tamanho .exe, o tempo de análise, é claro, também aumentou). Abrimos uma janela com linhas, que desta vez foram 51k. Através de
Ctrl + F
, procuramos
CMSG_PING
e ... não o encontramos. Ao inserir o código caractere por caractere, você pode observar este resultado:

Por alguma razão, a IDA colocou o objeto
Opcode.proto
inteiro em uma linha. Clique duas vezes neste local no código e veja que a estrutura é descrita da mesma maneira que no arquivo .exe, para que você possa cortá-lo e convertê-lo em
Enum
.
Por fim, vale lembrar como, nos comentários do artigo anterior,
aml sugeriu que a estrutura de mensagens do jogo é uma implementação de
Buffers de
Protocolo . Se você olhar atentamente para o código em um arquivo binário, poderá ver que a descrição do
Opcode
também está neste formato.

Escreveremos um modelo de analisador para 010Editor para obter todos os valores de código.
Código do tipo embalado * atualizado para 010Pequenas alterações de tipo contêm validação de rótulo de campo para ignorar as que estão faltando.
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._);
O resultado é algo como isto:

Mais interessante! Você notou
pb
na descrição do objeto? Seria necessário procurar outras linhas, de repente, existem muitos desses objetos?

Os resultados são extremamente inesperados. Aparentemente, o arquivo executável do jogo descreve muitos tipos de dados, incluindo enumerações e formatos de mensagens entre o servidor e o cliente. Aqui está um exemplo de uma descrição de um tipo que descreve a posição de um objeto no mundo:

Uma pesquisa rápida revelou dois lugares grandes com descrições de tipos, embora um exame mais detalhado provavelmente revelasse outros lugares pequenos. Depois de cortá-las, escrevi um pequeno script em C # para separar as descrições por arquivo (na estrutura é semelhante à descrição da lista de códigos de eventos) - é mais fácil analisá-las no 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; } }
Não analisarei o formato para descrever estruturas em detalhes, porque ou é específico para o jogo em questão ou é um formato geralmente aceito nos
Protocol Buffers
(se alguém tiver certeza, indique nos comentários). Pelo que pude detectar:
- descrição também vem no formato de
Protocol Buffers
; - a descrição de cada campo contém seu nome, número e tipo de dados para os quais sua própria tabela de tipos foi usada:
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; } };
- se o tipo de dados é uma enumeração ou estrutura, havia um link para o objeto desejado.
Bem, a última coisa que nos resta é usar as informações recebidas em nosso aplicativo de escuta: analisar mensagens usando a biblioteca
protobuf-net
. Conecte a biblioteca via NuGet, adicione
using ProtoBuf;
e você pode criar classes para descrever mensagens. Veja um exemplo de um artigo anterior: movimento de caracteres. A descrição formatada do formato ao destacar segmentos é mais ou menos assim:

A saída de depuração permite criar uma breve descrição a partir disso:
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
Agora você pode criar a classe apropriada usando a biblioteca
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}"; } }
Para comparação, aqui está um modelo para o mesmo evento de um artigo anterior:
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>; };
Ao herdar a classe
Event
, podemos substituir o método
ParseData
desserializando os dados do pacote:
class CMSG_MOVE_INFO : Event { private MoveInfo _message; [...] public override void ParseData() { _message = MoveInfo.Deserialize(GetPayload()); } public override string ToString() { return _message.ToString(); } }
Isso é tudo. O próximo passo é redirecionar o tráfego do jogo para o nosso servidor proxy para fins de injeção, falsificação e corte de pacotes.