From f0343bc1ea8ccaaaa27188e65777a8977df6284b Mon Sep 17 00:00:00 2001 From: neckfire Date: Wed, 15 Jul 2026 01:13:03 +0200 Subject: [PATCH] =?UTF-8?q?gameserver:=20consolidate=20netcode=20R&D=20?= =?UTF-8?q?=E2=80=94=20remove=20diag=20spam,=20document=20DTLS-PSK=20wall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Keep the R3.5.0 header-bit fix (bunch alignment breakthrough) - Remove per-packet [HS-RAW]/[HS-HDR]/[HS-BUNCH] diagnostics - Document the DTLS-PSK encryption wall + packed-binary finding in NETCODE-RND.md - UWorld NMT_Hello: honest comment on why the connection stops here Co-Authored-By: Claude Opus 4.8 --- NETCODE-RND.md | 90 +++++++++++++++++++++++ src/Prospect.Unreal/Net/UNetConnection.cs | 21 ------ src/Prospect.Unreal/Runtime/UWorld.cs | 31 +++++--- 3 files changed, 112 insertions(+), 30 deletions(-) create mode 100644 NETCODE-RND.md diff --git a/NETCODE-RND.md b/NETCODE-RND.md new file mode 100644 index 0000000..c66d58a --- /dev/null +++ b/NETCODE-RND.md @@ -0,0 +1,90 @@ +# The Cycle: Frontier — serveur dédié gameplay (R&D netcode) + +Branche `game-server`. Objectif : un serveur de jeu autoritatif écrit from scratch +(Prospect.Unreal, réimplémentation C# du netcode Unreal) pour du vrai co-op, sans +passer par un client-hôte P2P. **Statut : bloqué au chiffrement (voir plus bas).** + +Client cible : **UE4 build `R3.5.0`** (`4.27.2` netcode), Steam depot 868271. + +## Ce qui fonctionne (murs franchis) + +La connexion d'un vrai client va jusqu'à l'entrée du login : + +``` +Handshake stateless UDP (cookie/challenge) ✅ +Reconstruction du paquet (PacketHandler) ✅ +Séquençage des paquets (adopt des seq client) ✅ +Alignement des bunches ✅ ← percée +Canal de contrôle ouvert ✅ +NMT_Hello reçu et parsé ✅ +Transition login Hello → Login ✅ +Chiffrement DTLS-PSK ❌ ← mur final +``` + +### Percée : le bit de header spécifique R3.5.0 + +Le client écrit **un bit de plus** entre l'historique d'ack du `FNetPacketNotify` et +le payload packet-info, que l'UE 4.27 stock (EngineNetVer 16) n'a pas. Décodage +bit-à-bit d'un vrai paquet : l'en-tête fait **65 bits, pas 64**. En consommant ce bit +(`bCycleExtraHeaderBit` dans `UNetConnection.ReceivedPacket`), tout se réaligne : +`bHasPacketInfoPayload`, l'horloge jitter (10 bits) et `bHasServerFrameTime` tombent +juste, et le premier bunch du canal de contrôle parse proprement (ChIndex 0, bOpen, +bReliable = NMT_Hello). Sans ce fix, le `ChIndex` sortait en vrac (~1 049 000) et le +serveur droppait/plantait. + +## Le mur final : chiffrement DTLS-PSK + +Le client **exige** le chiffrement. Établi par reverse-engineering : + +- `NMT_Hello` porte `EncryptionToken` = le **PlayFab user_id** du joueur + (ex. `92EBCFE8C3EAF3AC`). Vu dans l'URL de connexion du client : + `...?EntityToken=?EncryptionToken=92EBCFE8C3EAF3AC`. +- Pile PacketHandler du client (ses propres logs) : + `[DTLSHandlerComponent, StatelessConnectHandlerComponent]`. +- Le exe embarque les suites **`ECDHE-PSK-AES256-*`, `DHE-PSK-AES256-GCM-SHA384`**, + la cvar **`DTLS.PreSharedKeys`**, et `DTLSPSKClientCallback` / `DTLSPSKServerCallback` + → **DTLS en mode PSK** (clé pré-partagée 32 octets, identité = user_id). +- Compression : **OodleNetwork** compilé, mais **aucun dictionnaire `.udic`** → + pass-through (les paquets ne sont ni compressés ni chiffrés au niveau paquet ; + entropie faible + longues suites de zéros le confirment). + +Proposer un challenge en clair (sans `NMT_EncryptionAck`) ne marche pas : le client +ferme le canal de contrôle juste après. + +Pour finir il faudrait : (1) un **serveur DTLS-PSK** collé au framing du +`DTLSHandlerComponent` d'UE, et (2) la **PSK de 32 octets** dérivée par le client à +partir du user_id/EntityToken. + +## Pourquoi la clé est inaccessible (statique) + +L'exe `Prospect-Win64-Shipping.exe` est **packé/chiffré** (protection anti-triche, +BattlEye) : + +- **Entropie de `.text` = 8.000** (maximum = aléatoire/chiffré ; du code normal ≈ 6.3). +- `.rdata` = 4.96 (normal → les strings restent lisibles, d'où les découvertes ci-dessus). +- Un scan brut du `.text` (76 Mo) ne trouve que **~76 instructions** → niveau du bruit : + ce n'est pas du code sur disque, c'est du chiffré déchiffré au runtime. + +Conséquence : Ghidra / radare2 n'analysent que du ciphertext ; **aucune référence** aux +fonctions de chiffrement n'est trouvable statiquement. La dérivation de la PSK vit dans +ce code chiffré. + +**Seule voie restante (non tentée)** : dump mémoire au runtime. Le jeu tourne sous +Proton/Linux (BattlEye n'a pas de driver kernel sous Linux) → un autre process Linux +peut lire `/proc//mem` et récupérer le `.text` **déchiffré**, puis l'analyser dans +Ghidra pour retrouver la dérivation. Zone grise ToS, plusieurs étapes. + +## Fichiers clés + +- `src/Prospect.Server.Game/Program.cs` — hôte du serveur de jeu (map Station, GameSession). +- `src/Prospect.Unreal/Net/UNetConnection.cs` — `ReceivedPacket` : fix du bit de header + (`bCycleExtraHeaderBit`), adopt des séquences client, drop gracieux des bunches. +- `src/Prospect.Unreal/Runtime/UWorld.cs` — `NotifyControlMessage` : Hello/Login ; + le `else` du bloc `NMT.Hello` documente le mur DTLS-PSK. + +## Verdict + +On a amené un serveur dédié gameplay The Cycle plus loin qu'aucun projet public connu +(le projet communautaire deiteris/Prospect n'émule que les services en ligne, pas le +netcode de jeu). Le mur restant — DTLS-PSK dont la clé est derrière un packer +anti-triche — est un chantier crypto + RE dynamique d'un autre ordre de grandeur. diff --git a/src/Prospect.Unreal/Net/UNetConnection.cs b/src/Prospect.Unreal/Net/UNetConnection.cs index dbf1b04..53b5749 100644 --- a/src/Prospect.Unreal/Net/UNetConnection.cs +++ b/src/Prospect.Unreal/Net/UNetConnection.cs @@ -501,21 +501,6 @@ public abstract class UNetConnection : UPlayer var resetReaderMark = new FBitReaderMark(reader); var channelsToClose = new List(); - // TEMP DIAG: dump the raw packet bit-stream (LSB-first per byte) so we can - // hand-decode the header/bunch alignment. Non-destructive (position restored). - { - var peekMark = new FBitReaderMark(reader); - var totalDiag = reader.GetBitsLeft(); - var takeDiag = Math.Min(totalDiag, 512); - var sbDiag = new System.Text.StringBuilder(takeDiag); - for (var i = 0; i < takeDiag; i++) - { - sbDiag.Append(reader.ReadBit() ? '1' : '0'); - } - peekMark.Pop(reader); - Logger.Information("[HS-RAW] totalBits={T} bits={B}", totalDiag, sbDiag.ToString()); - } - if (_bInternalAck) { ++InPacketId; @@ -569,9 +554,6 @@ public abstract class UNetConnection : UPlayer } } - Logger.Information("[HS-HDR] seq={Seq} ackedSeq={Acked} historyWords={HW} posAfterHeader={Pos} bitsLeft={Left}", - header.Seq.Value, header.AckedSeq.Value, header.HistoryWordCount, reader.GetPosBits(), reader.GetBitsLeft()); - var packetSequenceDelta = PacketNotify.GetSequenceDelta(header); // The Cycle client doesn't derive its initial packet sequences from the handshake @@ -686,9 +668,6 @@ public abstract class UNetConnection : UPlayer bunch.bIsReplicationPaused = reader.ReadBit(); bunch.bReliable = reader.ReadBit(); - Logger.Information("[HS-BUNCH] startBit={Start} posBeforeChIndex={Pos} bitsLeft={Left} bControl={C} bOpen={O} bClose={Cl} bReliable={R} netVer={V}", - incomingStartPos, reader.GetPosBits(), reader.GetBitsLeft(), bControl, bunch.bOpen, bunch.bClose, bunch.bReliable, (int)bunch.EngineNetVer()); - if (bunch.EngineNetVer() < EEngineNetworkVersionHistory.HISTORY_MAX_ACTOR_CHANNELS_CUSTOMIZATION) { const int oldMaxActorChannels = 10240; diff --git a/src/Prospect.Unreal/Runtime/UWorld.cs b/src/Prospect.Unreal/Runtime/UWorld.cs index 97c569c..867d983 100644 --- a/src/Prospect.Unreal/Runtime/UWorld.cs +++ b/src/Prospect.Unreal/Runtime/UWorld.cs @@ -286,15 +286,28 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable } else { - // The Cycle client offers an EncryptionToken in NMT_Hello. A UE - // connection only switches to AES once the server replies with - // NMT_EncryptionAck — the token is an offer, not a requirement. - // Implementing the full AES-GCM packet handler is a large effort, - // so first proceed WITHOUT encryption: never send the ack and go - // straight to the challenge. If the client keeps talking plaintext - // we're done; if it insists on encryption it will drop here and we - // know AES is mandatory. - Logger.Warning("Client sent EncryptionToken ({Len} chars) — proceeding WITHOUT encryption (no EncryptionAck)", encryptionToken.Length); + // R&D WALL — The Cycle mandates encryption on the game connection. + // The client's NMT_Hello carries EncryptionToken = its PlayFab + // user_id (e.g. "92EBCFE8C3EAF3AC"), and the client PacketHandler + // stack is [DTLSHandlerComponent, StatelessConnectHandlerComponent] + // (confirmed from the client's own logs). Encryption is therefore + // DTLS in PSK mode (the shipping exe bundles ECDHE/DHE-PSK-AES256 + // cipher suites + a "DTLS.PreSharedKeys" cvar + DTLSPSK*Callback). + // + // Proceeding to the challenge WITHOUT sending NMT_EncryptionAck does + // NOT work: the client requires encryption and closes the control + // channel immediately after the plaintext challenge. + // + // To finish this we'd need (1) a DTLS-PSK server that matches UE's + // DTLSHandlerComponent framing, and (2) the 32-byte PSK the client + // derives per user_id. The derivation lives in the client's code, + // which cannot be recovered statically: the exe is packed/encrypted + // (.text entropy = 8.0), so Ghidra/radare2 see only ciphertext. The + // only route left is a runtime memory dump of the decrypted image. + // + // For now: log and proceed to the challenge so the flow is visible + // in logs; the client will close afterwards. + Logger.Warning("Client requires DTLS-PSK encryption (EncryptionToken={Token}); server-side DTLS not implemented — connection will be dropped by client", encryptionToken); connection.SendChallengeControlMessage(); } }