Dies ist der zweite Teil einer Reihe von Artikeln zur Analyse des Netzwerkverkehrs von mobilen MMORPGs. Beispielschleifenthemen:
- Analysieren Sie das Nachrichtenformat zwischen Server und Client.
- Schreiben einer Höranwendung, um den Spielverkehr auf bequeme Weise anzuzeigen.
- Abfangen des Datenverkehrs und dessen Änderung mithilfe eines Nicht-HTTP-Proxyservers.
- Die ersten Schritte zu Ihrem eigenen ("Raubkopien") Server.
In diesem Teil werde ich die Erstellung einer Listening-Anwendung (Sniffer) beschreiben, mit der wir Ereignisse nach Typ und Quelle filtern, Informationen zur Nachricht anzeigen und selektiv zur Analyse speichern sowie ein wenig in die ausführbare Datei des Spiels ("binär") gelangen können unterstützende Informationen und Unterstützung für
Protokollpuffer zur App hinzufügen. Interessiert frage ich nach Katze.
Erforderliche Werkzeuge
Um die unten beschriebenen Schritte wiederholen zu können, benötigen Sie:
- Wireshark für die Paketanalyse;
- .NET
- PcapDotNet- Bibliothek für die Arbeit mit WinPcap;
- protobuf-net Bibliothek für die Arbeit mit Protokollpuffern.
Schreiben einer Höranwendung
Wie wir uns aus dem
vorherigen Artikel erinnern, kommuniziert das Spiel über das TCP-Protokoll und im Rahmen der Sitzung mit nur einem Server und an einem Port. Um den Spielverkehr analysieren zu können, müssen wir die folgenden Aufgaben ausführen:
- Pakete eines mobilen Geräts abfangen;
- Filterspielpakete;
- Fügen Sie die Daten des nächsten Pakets zur weiteren Verarbeitung zum Puffer hinzu.
- Holen Sie sich Spielereignisse aus Puffern, sobald diese gefüllt sind.
Diese Aktionen werden in der
Sniffer
Klasse implementiert, die die PcapDotNet-Bibliothek zum Abfangen von Paketen verwendet. Bei der
Sniff
Methode übergeben wir die IP-Adresse des Adapters (tatsächlich ist dies die Adresse des PCs, von dem Wi-Fi für das mobile Gerät innerhalb desselben Netzwerks verteilt wird), die IP-Adresse des mobilen Geräts und die IP-Adresse des Servers. Aufgrund der Inkonsistenz der letzten beiden (nach monatelanger Überwachung verschiedener Plattformen und Server stellte sich heraus, dass der Server aus einem Pool von ~ 50 Servern ausgewählt wurde, von denen jeder noch 5-7 mögliche Ports hat), übertrage ich nur die ersten drei Oktette. Die Verwendung dieser Filterung ist in der
IsTargetPacket
Methode sichtbar.
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) {
Großartig, jetzt haben wir zwei Puffer mit Paketdaten vom Client und Server. Erinnern Sie sich an das Format der Ereignisse zwischen dem Spiel und dem Server:
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">; };
Auf dieser Grundlage können Sie eine
Event
erstellen:
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; } }
Die
Event
wird als Basisklasse für alle Ereignisse im Spiel verwendet. Hier ist eine Beispielklasse für das
Ping
Ereignis:
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."; } }
Nachdem wir die Ereignisklasse haben, können wir
Sniffer
Methoden hinzufügen:
private void ProcessBuffer(ref List<byte> buffer) { if (buffer.Count > 0) { while (Active) { if (buffer.Count > 4)
Erstellen Sie eine Formularklasse, die das Abhören auslöst:
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)); } }
Fertig! Jetzt können Sie einige Tabellen hinzufügen, um die Liste der Ereignisse zu verwalten (
_eventTypesFilter
gefüllt) und in Echtzeit
events_table
(die Haupttabelle
events_table
). Zum Beispiel habe ich nach folgenden Kriterien gefiltert (
FilterEvent
Methode):
- Ereignisse vom Kunden anzeigen;
- Ereignisse vom Server anzeigen;
- Anzeige unbekannter Ereignisse;
- Ausgewählte bekannte Ereignisse anzeigen.
Das ausführbare Spiel lernen
Obwohl es jetzt möglich ist, die Ereignisse des Spiels ohne Probleme zu analysieren, ist eine Menge manueller Arbeit erforderlich, um nicht nur die Bedeutung aller Ereigniscodes, sondern auch die Struktur der Nutzlast zu bestimmen, was sehr schwierig sein wird, insbesondere wenn sie sich in Abhängigkeit von einigen Feldern ändert. Ich beschloss, nach Informationen in der ausführbaren Datei des Spiels zu suchen. Da das Spiel plattformübergreifend ist (verfügbar unter Windows, iOS und Android), stehen die folgenden Optionen zur Analyse zur Verfügung:
- .exe-Datei (Ich habe mich im Pfad C befunden: / Programme / WindowsApps /% appname% /;
- eine binäre iOS-Datei, die von Apple verschlüsselt wird, aber mit JailBreak können Sie die entschlüsselte Version mit Crackulous oder ähnlichem erhalten;
- Gemeinsame Android-Bibliothek Es befindet sich im Pfad / data / data /% app-vendor-name% / lib /.
Da ich keine Ahnung hatte, welche Architektur ich für Android und iOS wählen sollte, begann ich mit einer EXE-Datei. Wir laden die Binärdatei in die IDA, wir sehen die Auswahl der Architekturen.

Der Zweck unserer Suche sind einige sehr nützliche Zeilen, was bedeutet, dass die Dekompilierung des Assemblers nicht in den Plänen enthalten ist. Wählen Sie jedoch für den Fall "ausführbare Datei 80386" aus, da die Optionen "Binärdatei" und "ausführbare MS-DOS-Datei" eindeutig nicht geeignet sind. Klicken Sie auf "OK", warten Sie, bis die Datei in die Datenbank geladen wurde, und warten Sie, bis die Analyse der Datei abgeschlossen ist. Das Ende der Analyse lässt sich daran erkennen, dass in der Statusleiste unten links der folgende Status angezeigt wird:

Wechseln Sie zur Registerkarte Strings (View / Open subviews / Strings oder
Shift + F12
). Der Zeilengenerierungsprozess kann einige Zeit dauern. In meinem Fall wurden ~ 47.000 Zeilen gefunden. Adressen für die Position von Zeichenfolgen haben das Präfix der Form
.data
,
.rdata
und
anderer . In meinem Fall befanden sich alle „interessanten“ Zeilen im Abschnitt
.rdata
, dessen Größe ~ 44,5.000 Datensätze betrug. Wenn Sie durch die Tabelle schauen, können Sie sehen:
- Fehlermeldungen und Abfragesegmente während der Anmeldephase;
- Fehlerzeichenfolgen und Initialisierungsinformationen für das Spiel, die Spiel-Engine und die Schnittstellen;
- viel Müll;
- eine Liste der Spieltische auf der Client-Seite;
- verwendete Werte der Spiel-Engine im Spiel;
- Liste der Effekte;
- riesige Liste von Schnittstellenlokalisierungsschlüsseln;
- usw.
Endlich kommt das Ende der Tabelle, wonach wir gesucht haben.

Dies ist eine Liste von Ereigniscodes zwischen Client und Server. Dies kann unser Leben bei der Analyse des Netzwerkprotokolls des Spiels vereinfachen. Aber wir werden hier nicht aufhören! Es muss geprüft werden, ob es möglich ist, den numerischen Wert des Ereigniscodes irgendwie zu erhalten. Wir sehen die "vertrauten" aus den vorherigen
CMSG_PING
und
SMSG_PONG
mit den Codes 30 (
1E 16
) bzw. 31 (
1F 16
). Doppelklicken Sie auf die Zeile, um zu dieser Stelle im Code zu gelangen.

In der Tat ist unmittelbar nach den Zeichenfolgenwerten der Codes eine Folge von
0x10 0x1E
und
0x10 0x1F
. Das bedeutet, dass Sie die gesamte Tabelle analysieren und eine Liste der Ereignisse und ihres numerischen Werts erhalten können, was die Analyse des Protokolls weiter vereinfacht.
Leider bleibt die Windows-Version des Spiels um viele Versionen hinter den mobilen Versionen zurück, und daher sind die Informationen aus .exe nicht relevant, und obwohl dies hilfreich sein kann, sollten Sie sich nicht vollständig darauf verlassen. Als nächstes habe ich beschlossen, die dynamische Bibliothek mit Android zu studieren, da ich in einem Forum gesehen habe, dass dort im Gegensatz zu iOS-Binärdateien viele Metainformationen zu Klassen enthalten sind.
CMSG_PING
ergab eine Suche in der
CMSG_PING
keine Ergebnisse.
Ohne Hoffnung mache ich die gleiche Suche in der iOS-Binärdatei - unglaublich, aber die gleichen Daten sind dort wie in .exe! Laden Sie die Datei auf die IDA hoch.

Ich wähle die erste vorgeschlagene Option, da ich nicht sicher bin, welche benötigt wird. Wieder warten wir auf das Ende der Dateianalyse (die Binärdatei ist fast viermal größer als .exe, die Analysezeit hat sich natürlich auch erhöht). Wir öffnen ein Fenster mit Linien, die sich diesmal als 51k herausstellten. Über
Ctrl + F
suchen wir nach
CMSG_PING
und ... wir finden es nicht. Wenn Sie den Code zeichenweise eingeben, können Sie folgendes Ergebnis feststellen:

Aus irgendeinem Grund hat die IDA das gesamte
Opcode.proto
Objekt in eine Zeile
Opcode.proto
. Doppelklicken Sie auf diese Stelle im Code und sehen Sie, dass die Struktur auf dieselbe Weise wie in der EXE-Datei beschrieben wird, sodass Sie sie ausschneiden und in
Enum
konvertieren können.
Abschließend sei daran erinnert, wie
aml in den Kommentaren zum vorherigen Artikel vorgeschlagen hat, dass die Nachrichtenstruktur des Spiels eine Implementierung von
Protokollpuffern ist . Wenn Sie sich den Code in einer Binärdatei genau ansehen, sehen Sie, dass die
Opcode
Beschreibung auch in diesem Format vorliegt.

Wir werden eine Parser-Vorlage für 010Editor schreiben, um alle Codewerte abzurufen.
Packed * Type Code für 010Editor aktualisiertKleinere Typänderungen enthalten die Validierung der Feldbezeichnung, um fehlende zu überspringen.
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._);
Das Ergebnis ist ungefähr so:

Interessanter! Pb in der Objektbeschreibung bemerkt? Es wäre notwendig, nach anderen Linien zu suchen, plötzlich gibt es viele solcher Objekte?

Die Ergebnisse sind äußerst unerwartet. Anscheinend beschreibt die ausführbare Datei des Spiels viele Arten von Daten, einschließlich Aufzählungen und Nachrichtenformaten zwischen dem Server und dem Client. Hier ist ein Beispiel für eine Beschreibung eines Typs, der die Position eines Objekts in der Welt beschreibt:

Eine schnelle Suche ergab zwei große Orte mit Typbeschreibungen, obwohl eine genauere Untersuchung wahrscheinlich andere kleine Orte aufdecken würde. Nachdem ich sie geschnitten hatte, schrieb ich ein kleines Skript in C #, um die Beschreibungen nach Dateien zu trennen (in der Struktur ähnelt dies der Beschreibung der Liste der Ereigniscodes) - es ist einfacher, sie in 010Editor zu analysieren.
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; } }
Ich werde das Format zur detaillierten Beschreibung von Strukturen nicht analysieren, weil Entweder ist es spezifisch für das betreffende Spiel oder es ist ein Format, das in
Protocol Buffers
allgemein akzeptiert wird (wenn jemand sicher weiß, geben Sie dies bitte in den Kommentaren an). Nach allem, was ich feststellen konnte:
- Beschreibung kommt auch im
Protocol Buffers
; - Die Beschreibung jedes Felds enthält seinen Namen, seine Nummer und seinen Datentyp, für den eine eigene Typentabelle verwendet wurde:
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; } };
- Wenn der Datentyp eine Aufzählung oder Struktur ist, gab es eine Verknüpfung zum gewünschten Objekt.
Nun, das Letzte, was uns bleibt, ist die Verwendung der in unserer
protobuf-net
empfangenen Informationen:
protobuf-net
Nachrichten mithilfe der
protobuf-net
Bibliothek. Verbinden Sie die Bibliothek über NuGet und fügen Sie sie
using ProtoBuf;
und Sie können Klassen erstellen, um Nachrichten zu beschreiben. Nehmen Sie ein Beispiel aus einem früheren Artikel: Charakterbewegung. Die formatierte Beschreibung des Formats beim Hervorheben von Segmenten sieht ungefähr so aus:

Mit der Debug-Ausgabe können Sie eine kurze Beschreibung daraus erstellen:
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
Jetzt können Sie die entsprechende Klasse mit der
protobuf-net
Bibliothek erstellen.
[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}"; } }
Zum Vergleich hier eine Vorlage für dasselbe Ereignis aus einem vorherigen Artikel:
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>; };
Beim Erben der
Event
Klasse können wir die
ParseData
Methode
ParseData
, indem wir die
ParseData
deserialisieren:
class CMSG_MOVE_INFO : Event { private MoveInfo _message; [...] public override void ParseData() { _message = MoveInfo.Deserialize(GetPayload()); } public override string ToString() { return _message.ToString(); } }
Das ist alles. Der nächste Schritt besteht darin, den Spielverkehr auf unseren Proxyserver umzuleiten, um Pakete zu injizieren, zu fälschen und zu schneiden.