Création d'une application d'écoute pour visualiser le trafic MMORPG mobile

Il s'agit de la deuxième partie d'une série d'articles sur l'analyse du trafic réseau des MMORPG mobiles. Exemples de sujets de boucle:

  1. Analyser le format des messages entre le serveur et le client.
  2. Écrire une application d'écoute pour visualiser le trafic du jeu de manière pratique.
  3. Interception du trafic et sa modification à l'aide d'un serveur proxy non HTTP.
  4. Les premières étapes vers votre propre serveur ("piraté").

Dans cette partie, je décrirai la création d'une application d'écoute (sniffer), qui nous permettra de filtrer les événements par leur type et leur source, d'afficher des informations sur le message et de les enregistrer de manière sélective pour l'analyse, et également d'accéder un peu au fichier exécutable du jeu («binaire») informations de support et ajouter la prise en charge des tampons de protocole à l'application. Intéressé, je demande chat.

Outils requis


Pour pouvoir répéter les étapes décrites ci-dessous, vous aurez besoin de:

  • Wireshark pour l'analyse des paquets;
  • .NET
  • Bibliothèque PcapDotNet pour travailler avec WinPcap;
  • bibliothèque protobuf-net pour travailler avec les tampons de protocole.

Écrire une application d'écoute


Comme nous nous en souvenons de l' article précédent , le jeu communique via le protocole TCP, et dans le cadre de la session, il le fait avec un seul serveur et sur un port. Pour pouvoir analyser le trafic de jeu, nous devons effectuer les tâches suivantes:

  • intercepter les paquets d'un appareil mobile;
  • filtrer les packages de jeux;
  • ajouter les données du paquet suivant au tampon pour un traitement ultérieur;
  • Obtenez les événements de jeu à partir des tampons lorsqu'ils sont remplis.

Ces actions sont implémentées dans la classe Sniffer , qui utilise la bibliothèque PcapDotNet pour intercepter les paquets. Dans la méthode Sniff , nous transmettons l'adresse IP de l'adaptateur (en fait, c'est l'adresse du PC à partir de laquelle le Wi-Fi est distribué pour l'appareil mobile au sein du même réseau), l'adresse IP de l'appareil mobile et l'adresse IP du serveur. En raison de l'incohérence des deux derniers (après des mois de surveillance de plates-formes et de serveurs différents, il s'est avéré que le serveur est sélectionné parmi un pool de ~ 50 serveurs, chacun ayant toujours 5-7 ports possibles), je ne transmets que les trois premiers octets. L'utilisation de ce filtrage est visible dans la méthode 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; } } 

Très bien, nous avons maintenant deux tampons avec les données des paquets du client et du serveur. Rappelez le format des événements entre le jeu et le serveur:

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

Sur cette base, vous pouvez créer une classe d'événements 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; } } 

La classe d' Event sera utilisée comme classe de base pour tous les événements du jeu. Voici un exemple de classe pour l'événement 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."; } } 

Maintenant que nous avons la classe d'événements, nous pouvons ajouter des méthodes à 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; } 

Créez une classe de formulaire qui déclenchera l'écoute électronique:

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

C'est fait! Vous pouvez maintenant ajouter quelques tables pour gérer la liste des événements (à travers elle _eventTypesFilter remplie) et afficher en temps réel (la table principale events_table ). Par exemple, j'ai filtré selon les critères suivants (méthode FilterEvent ):

  • montrer les événements du client;
  • afficher les événements du serveur;
  • affichage d'événements inconnus;
  • Afficher les événements connus sélectionnés.

Apprendre l'exécutable du jeu


Bien qu'il soit désormais possible d'analyser les événements du jeu sans problème, il y a une énorme quantité de travail manuel pour déterminer non seulement la signification de tous les codes d'événement, mais aussi la structure de la charge utile, ce qui sera assez difficile, surtout si elle change en fonction de certains champs. J'ai décidé de chercher des informations dans le fichier exécutable du jeu. Comme le jeu est multiplateforme (disponible sur Windows, iOS et Android), les options suivantes sont disponibles pour l'analyse:

  • Fichier .exe (j'ai localisé sur le chemin C: / Program Files / WindowsApps /% appname% /;
  • un fichier iOS binaire qui est crypté par Apple, mais avec JailBreak, vous pouvez obtenir la version décryptée en utilisant Crackulous ou similaire;
  • Bibliothèque partagée Android Il se trouve sur le chemin / data / data /% app-vendor-name% / lib /.

N'ayant aucune idée de l'architecture à choisir pour Android et iOS, j'ai commencé avec un fichier .exe. On charge le binaire dans l'IDA, on voit le choix des architectures.



Le but de notre recherche est quelques lignes très utiles, ce qui signifie que la décompilation de l'assembleur n'est pas incluse dans les plans, mais juste au cas où, sélectionnez "exécutable 80386", car les options "Fichier binaire" et "Exécutable MS-DOS" ne sont clairement pas appropriées. Cliquez sur "OK", attendez que le fichier soit chargé dans la base de données, et il est conseillé d'attendre la fin de l'analyse du fichier. La fin de l'analyse peut être trouvée par le fait que dans la barre d'état en bas à gauche il y aura l'état suivant:



Accédez à l'onglet Chaînes (Afficher / Ouvrir les sous-vues / Chaînes ou Shift + F12 ). Le processus de génération de ligne peut prendre un certain temps. Dans mon cas, ~ 47k lignes ont été trouvées. Les adresses de l'emplacement des chaînes ont un préfixe de la forme .data , .rdata et autres . Dans mon cas, toutes les lignes «intéressantes» se trouvaient dans la section .rdata , dont la taille était d'environ 44,5 k enregistrements. En parcourant le tableau, vous pouvez voir:

  • messages d'erreur et segments de requête pendant la phase de connexion;
  • chaînes d'erreur et informations d'initialisation pour le jeu, le moteur de jeu, les interfaces;
  • beaucoup de déchets;
  • une liste des tables de jeu côté client;
  • utilisé les valeurs du moteur de jeu dans le jeu;
  • liste des effets;
  • énorme liste de clés de localisation d'interface;
  • etc.

Enfin, plus près de la fin du tableau vient ce que nous recherchions.



Il s'agit d'une liste de codes d'événements entre le client et le serveur. Cela peut simplifier nos vies lors de l'analyse du protocole réseau du jeu. Mais nous ne nous arrêterons pas là! Il est nécessaire de vérifier s'il est possible d'obtenir en quelque sorte la valeur numérique du code d'événement. Nous voyons le "familier" des codes d'article précédents CMSG_PING et SMSG_PONG , avec les codes 30 ( 1E 16 ) et 31 ( 1F 16 ), respectivement. Double-cliquez sur la ligne pour accéder à cet endroit dans le code.



En effet, immédiatement après les valeurs de chaîne des codes se trouve une séquence de 0x10 0x1E et 0x10 0x1F . Eh bien, cela signifie que vous pouvez analyser la table entière et obtenir une liste des événements et leur valeur numérique, ce qui simplifiera encore l'analyse du protocole.

Malheureusement, la version Windows du jeu est à la traîne des versions mobiles par de nombreuses versions, et donc les informations de .exe ne sont pas pertinentes, et bien qu'elles puissent aider, vous ne devriez pas vous y fier entièrement. Ensuite, j'ai décidé d'étudier la bibliothèque dynamique avec Android, comme je l'ai vu sur un forum qui, contrairement aux binaires iOS, contient beaucoup de méta-informations sur les classes. Mais hélas, une recherche dans le CMSG_PING valeurs CMSG_PING n'a pas renvoyé de résultats.

Sans espoir, je fais la même recherche dans le binaire iOS - incroyable, mais les données se sont avérées être les mêmes qu'en .exe! Téléchargez le fichier dans l'IDA.



Je choisis la première option proposée, car je ne sais pas laquelle est nécessaire. Encore une fois, nous attendons la fin de l'analyse du fichier (le binaire est presque 4 fois plus grand en taille .exe, le temps d'analyse, bien sûr, a également augmenté). Nous ouvrons une fenêtre avec des lignes, qui cette fois s'est avérée être 51k. Grâce à Ctrl + F nous recherchons CMSG_PING et ... nous ne le trouvons pas. En entrant le code caractère par caractère, vous pouvez remarquer ce résultat:



Pour une raison quelconque, l'IDA a mis l'intégralité de l'objet Opcode.proto sur une seule ligne. Double-cliquez sur cet endroit dans le code et vérifiez que la structure est décrite de la même manière que dans le fichier .exe, vous pouvez donc la couper et la convertir en Enum .

Enfin, il convient de rappeler comment, dans les commentaires sur l'article précédent, aml a suggéré que la structure des messages du jeu est une implémentation de Protocol Buffers . Si vous regardez attentivement le code dans un fichier binaire, vous pouvez voir que la description de l' Opcode est également dans ce format.



Nous allons écrire un modèle d'analyseur pour 010Editor pour obtenir toutes les valeurs de code.

Code de type Packed * mis à jour pour 010Editor
Les modifications de type mineures contiennent une validation d'étiquette de champ pour ignorer celles qui manquent.

 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; 

Le résultat est quelque chose comme ceci:



Plus intéressant! Avez-vous remarqué pb dans la description de l'objet? Il faudrait chercher d'autres lignes, du coup il y a beaucoup de tels objets?



Les résultats sont extrêmement inattendus. Apparemment, le fichier exécutable du jeu décrit de nombreux types de données, y compris des énumérations et des formats de message entre le serveur et le client. Voici un exemple de description d'un type qui décrit la position d'un objet dans le monde:



Une recherche rapide a révélé deux grands endroits avec des descriptions de types, bien qu'un examen plus approfondi révélerait probablement d'autres petits endroits. Après les avoir découpés, j'ai écrit un petit script en C # pour séparer les descriptions par fichier (dans la structure c'est similaire à la description de la liste des codes d'événements) - il est plus facile de les analyser dans 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; } } 

Je n'analyserai pas le format de description détaillée des structures, car soit il est spécifique au jeu en question, soit il s'agit d'un format généralement accepté dans les Protocol Buffers (si quelqu'un en est sûr, veuillez l'indiquer dans les commentaires). D'après ce que j'ai pu détecter:

  • la description est également disponible au format Protocol Buffers ;
  • la description de chaque champ contient son nom, son numéro et son type de données, pour lesquels sa propre table de types a été utilisée:
     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 le type de données est une énumération ou une structure, il existe alors un lien vers l'objet souhaité.

Eh bien, la dernière chose qui nous reste est d'utiliser les informations reçues dans notre application d'écoute: analyser les messages en utilisant la bibliothèque protobuf-net . Connectez la bibliothèque via NuGet, ajoutez en using ProtoBuf; et vous pouvez créer des classes pour décrire les messages. Prenons un exemple d'un article précédent: le mouvement des personnages. La description formatée du format lors de la mise en surbrillance des segments ressemble à ceci:



La sortie de débogage vous permet de créer une courte description à partir de ceci:

 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 

Vous pouvez maintenant créer la classe appropriée à l'aide de la bibliothèque 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}"; } } 

À titre de comparaison, voici un modèle pour le même événement d'un article précédent:

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

Lors de l'héritage de la classe Event , nous pouvons remplacer la méthode ParseData en désérialisant les données du package:

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

C’est tout. La prochaine étape consiste à rediriger le trafic du jeu vers notre serveur proxy à des fins d'injection, d'usurpation et de découpe de packages.

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


All Articles