Crear una aplicación de escucha para ver el tráfico MMORPG móvil

Esta es la segunda parte de una serie de artículos sobre el análisis del tráfico de red de MMORPG móvil. Ejemplos de temas de bucle:

  1. Analizar el formato del mensaje entre el servidor y el cliente.
  2. Escribir una aplicación de escucha para ver el tráfico del juego de una manera conveniente.
  3. Intercepción de tráfico y su modificación utilizando un servidor proxy no HTTP.
  4. Los primeros pasos para su propio servidor ("pirateado").

En esta parte, describiré la creación de una aplicación de escucha (sniffer), que nos permitirá filtrar eventos por su tipo y fuente, mostrar información sobre el mensaje y guardarlos selectivamente para su análisis, y también entrar un poco en el archivo ejecutable del juego ("binario") información de soporte y agregue soporte de Protocol Buffers a la aplicación. Interesado, pido gato.

Herramientas requeridas


Para poder repetir los pasos que se describen a continuación, necesitará:

  • Wireshark para el análisis de paquetes;
  • .NET
  • Biblioteca PcapDotNet para trabajar con WinPcap;
  • biblioteca protobuf-net para trabajar con Protocol Buffers.

Escribir una aplicación para escuchar


Como recordamos del artículo anterior , el juego se comunica a través del protocolo TCP, y en el marco de la sesión lo hace con un solo servidor y en un puerto. Para poder analizar el tráfico del juego, debemos realizar las siguientes tareas:

  • interceptar paquetes de un dispositivo móvil;
  • filtrar paquetes de juegos;
  • agregue los datos del siguiente paquete al búfer para su posterior procesamiento;
  • Obtenga eventos del juego de los buffers a medida que se llenan.

Estas acciones se implementan en la clase Sniffer , que utiliza la biblioteca PcapDotNet para interceptar paquetes. En el método Sniff , pasamos la dirección IP del adaptador (de hecho, esta es la dirección de la PC desde la cual se distribuye Wi-Fi para el dispositivo móvil dentro de la misma red), la dirección IP del dispositivo móvil y la dirección IP del servidor. Debido a la inconsistencia de los dos últimos (después de meses de monitorear diferentes plataformas y servidores, resultó que el servidor se selecciona de un grupo de ~ 50 servidores, cada uno de los cuales todavía tiene 5-7 puertos posibles), transmito solo los primeros tres octetos. El uso de este filtrado es visible en el 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) { // TODO } public void Suspend() { Active = false; } public void Resume() { Active = true; } } 

Genial, ahora tenemos dos buffers con paquetes de datos del cliente y el servidor. Recuerde el formato de eventos entre el juego y el 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">; }; 

En base a esto, puede crear una clase de Event evento:

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

La clase Event se usará como la clase base para todos los eventos en el juego. Aquí hay una clase de ejemplo para el 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."; } } 

Ahora que tenemos la clase de evento, podemos agregar métodos a 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; } 

Cree una clase de formulario que activará la intervención 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)); } } 

Hecho Ahora puede agregar un par de tablas para administrar la lista de eventos (a través de ella _eventTypesFilter llena) y ver en tiempo real (la tabla principal events_table ). Por ejemplo, FilterEvent por los siguientes criterios (método FilterEvent ):

  • mostrar eventos del cliente;
  • mostrar eventos del servidor;
  • visualización de eventos desconocidos;
  • Mostrar eventos conocidos seleccionados.

Aprendiendo el ejecutable del juego


Aunque ahora es posible analizar los eventos del juego sin problemas, hay una gran cantidad de trabajo manual para determinar no solo el significado de todos los códigos de eventos, sino también la estructura de la carga útil, que será bastante difícil, especialmente si cambia dependiendo de algunos campos. Decidí buscar información en el archivo ejecutable del juego. Dado que el juego es multiplataforma (disponible en Windows, iOS y Android), las siguientes opciones están disponibles para su análisis:

  • archivo .exe (he localizado en la ruta C: / Archivos de programa / WindowsApps /% appname% /;
  • un archivo iOS binario encriptado por Apple, pero con JailBreak puede obtener la versión descifrada usando Crackulous o similar;
  • Biblioteca compartida de Android Se encuentra en la ruta / data / data /% app-vendor-name% / lib /.

Al no tener idea de qué arquitectura elegir para Android e iOS, comencé con un archivo .exe. Cargamos el binario en la IDA, vemos la elección de arquitecturas.



El propósito de nuestra búsqueda son algunas líneas muy útiles, lo que significa que la descompilación del ensamblador no está incluida en los planes, pero por si acaso, seleccione "ejecutable 80386", ya que las opciones "Archivo binario" y "Ejecutable MS-DOS" claramente no son adecuadas. Haga clic en "Aceptar", espere hasta que el archivo se cargue en la base de datos y es recomendable esperar hasta que se complete el análisis del archivo. El final del análisis se puede encontrar por el hecho de que en la barra de estado en la parte inferior izquierda estará el siguiente estado:



Vaya a la pestaña Cadenas (Ver / Abrir subvistas / Cadenas o Shift + F12 ). El proceso de generación de línea puede llevar algún tiempo. En mi caso, se encontraron ~ 47k líneas. Las direcciones de la ubicación de las cadenas tienen un prefijo de la forma .data , .rdata y otras . En mi caso, todas las líneas "interesantes" estaban en la sección .rdata , cuyo tamaño era de ~ 44.5k registros. Mirando a través de la mesa puedes ver:

  • mensajes de error y segmentos de consulta durante la fase de inicio de sesión;
  • cadenas de error e información de inicialización del juego, motor del juego, interfaces;
  • mucha basura;
  • una lista de mesas de juego en el lado del cliente;
  • valores usados ​​del motor del juego en el juego;
  • lista de efectos;
  • gran lista de teclas de localización de interfaz;
  • etc.

Finalmente, más cerca del final de la tabla viene lo que estábamos buscando.



Esta es una lista de códigos de eventos entre el cliente y el servidor. Esto puede simplificar nuestras vidas al analizar el protocolo de red del juego. ¡Pero no nos detendremos allí! Es necesario verificar si de alguna manera es posible obtener el valor numérico del código de evento. Vemos lo "familiar" de los códigos de artículos anteriores CMSG_PING y SMSG_PONG , con los códigos 30 ( 1E 16 ) y 31 ( 1F 16 ), respectivamente. Haga doble clic en la línea para ir a este lugar en el código.



De hecho, inmediatamente después de los valores de cadena de los códigos hay una secuencia de 0x10 0x1E y 0x10 0x1F . Bueno, eso significa que puede analizar toda la tabla y obtener una lista de eventos y su valor numérico, lo que simplificará aún más el análisis del protocolo.

Desafortunadamente, la versión de Windows del juego va a la zaga de las versiones móviles en muchas versiones y, por lo tanto, la información de .exe no es relevante, y aunque puede ayudar, no debes confiar en ella por completo. Luego, decidí estudiar la biblioteca dinámica con Android, como vi en un foro que, a diferencia de los binarios de iOS, contiene mucha metainformación sobre las clases. Pero, por desgracia, una búsqueda en el CMSG_PING valores CMSG_PING no CMSG_PING resultados.

Sin esperanza, estoy haciendo la misma búsqueda en el binario de iOS, increíble, ¡pero los datos allí fueron los mismos que en .exe! Suba el archivo a la IDA.



Elijo la primera opción propuesta, porque no estoy seguro de cuál es necesaria. Nuevamente, estamos esperando el final del análisis del archivo (el binario es casi 4 veces más grande en tamaño .exe, el tiempo de análisis, por supuesto, también aumentó). Abrimos una ventana con líneas, que esta vez resultó ser 51k. A través de Ctrl + F buscamos CMSG_PING y ... no lo encontramos. Al ingresar el código carácter por carácter, puede observar este resultado:



Por alguna razón, la IDA puso todo el objeto Opcode.proto en una línea. Haga doble clic en este lugar en el código y vea que la estructura se describe de la misma manera que en el archivo .exe, para que pueda cortarlo y convertirlo a Enum .

Finalmente, vale la pena recordar cómo, en los comentarios sobre el artículo anterior, aml sugirió que la estructura de mensajes del juego es una implementación de Protocol Buffers . Si observa detenidamente el código en un archivo binario, puede ver que la descripción del Opcode también está en este formato.



Escribiremos una plantilla de analizador para 010Editor para obtener todos los valores de código.

Código de tipo empaquetado * actualizado para 010Editor
Los cambios de tipo menores contienen validación de etiqueta de campo para omitir los que faltan.

 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; 

El resultado es algo como esto:



Más interesante! ¿Notó pb en la descripción del objeto? Sería necesario buscar otras líneas, ¿de repente hay muchos de esos objetos?



Los resultados son extremadamente inesperados. Aparentemente, el archivo ejecutable del juego describe muchos tipos de datos, incluidas enumeraciones y formatos de mensajes entre el servidor y el cliente. Aquí hay un ejemplo de una descripción de un tipo que describe la posición de un objeto en el mundo:



Una búsqueda rápida reveló dos lugares grandes con descripciones de tipos, aunque un examen más detallado probablemente revelaría otros lugares pequeños. Después de cortarlos, escribí un pequeño script en C # para separar las descripciones por archivo (en estructura, esto es similar a la descripción de la lista de códigos de eventos): es más fácil analizarlos en 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; } } 

No analizaré el formato para describir estructuras en detalle, porque o es específico para el juego en cuestión, o es un formato generalmente aceptado en Protocol Buffers (si alguien lo sabe con seguridad, indíquelo en los comentarios). Por lo que pude detectar:

  • la descripción también viene en formato Protocol Buffers ;
  • La descripción de cada campo contiene su nombre, número y tipo de datos para los que se utilizó su propia tabla de tipos:
     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; } }; 
  • si el tipo de datos es una enumeración o estructura, entonces había un enlace al objeto deseado.

Bueno, lo último que nos queda es usar la información recibida en nuestra aplicación de escucha: analizar mensajes usando la biblioteca protobuf-net . Conecte la biblioteca a través de NuGet, agregue using ProtoBuf; y puedes crear clases para describir mensajes. Tomemos un ejemplo de un artículo anterior: movimiento de personajes. La descripción formateada del formato al resaltar segmentos se ve así:



La salida de depuración le permite crear una breve descripción a partir de esto:

 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 

Ahora puede crear la clase apropiada utilizando la 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}"; } } 

A modo de comparación, aquí hay una plantilla para el mismo evento de un artículo 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>; }; 

Al heredar la clase Event , podemos anular el método ParseData deserializando los datos del paquete:

 class CMSG_MOVE_INFO : Event { private MoveInfo _message; [...] public override void ParseData() { _message = MoveInfo.Deserialize(GetPayload()); } public override string ToString() { return _message.ToString(); } } 

Eso es todo El siguiente paso es redirigir el tráfico del juego a nuestro servidor proxy con el fin de inyectar, falsificar y cortar paquetes.

Source: https://habr.com/ru/post/457480/


All Articles