Files
the-cycle/NETCODE-RND.md
T

7.8 KiB

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=<JWT>?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 / DTLSPSKServerCallbackDTLS 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/<pid>/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.csReceivedPacket : fix du bit de header (bCycleExtraHeaderBit), adopt des séquences client, drop gracieux des bunches.
  • src/Prospect.Unreal/Runtime/UWorld.csNotifyControlMessage : 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.

MAJ (2026-07-16) — mur DTLS franchi (côté serveur)

  • PSK récupérée : la clé 32 octets a été obtenue au runtime (dump du .text déchiffré du client via /proc/<pid>/mem sous Proton — BattlEye n'a pas de driver kernel sous Linux). Stockée dans Vault (secret/the-cycle, clé GAMESERVER_DTLS_PSKS, format user_id:hex32), jamais dans le repo.
  • Serveur DTLS-PSK implémenté (BouncyCastle) :
    • DtlsPskStore (env PROSPECT_DTLS_PSKS), DtlsPacketTransport (pont bloquant→paquet), ProspectPskTlsServer (DTLS 1.2, identité=user_id), DTLSHandlerComponent (pipeline).
    • UWorld sur NMT_Hello chiffré : PSK connue → NMT_EncryptionAck + handshake.
    • Compile (0 erreur). Non validé en live.
  • Reste (itération live obligatoire, invalidable hors client) :
    1. ordre exact du pipeline [DTLS, Stateless] et routage des records de handshake (émis via LowLevelSend — doivent porter le framing stateless attendu) ;
    2. framing DTLS-sur-PacketHandler d'UE (record layer, cookie DTLS éventuel) ;
    3. bascule handshake→données applicatives. Méthode : lancer Prospect.Server.Game avec la PSK, diriger le client dessus, itérer sur les logs serveur.

MAJ (2026-07-16, soir) — test LIVE contre le vrai client (preprod multi)

Setup : preprod multi (the-cycle-api-rd2, GAMESERVER_ADDRESS=192.168.1.136:7777) → le client voyage vers the-cycle-game (branche game-server, PSK via PROSPECT_DTLS_PSKS). user_id preprod = prod = 92EBCFE8C3EAF3AC (même DB ? non, ProspectDb_rd, mais même Id).

Observé (séquence réelle) :

  1. Handshake stateless : complet (challenge/cookie/ack, connexion acceptée).
  2. NMT_Hello reçu EN CLAIR avec EncryptionToken=92EBCFE8C3EAF3AC → PSK reconnue.
  3. On envoie NMT_EncryptionAck + NMT_Challenge. Le client NE ferme plus tout de suite (progrès vs l'état documenté « ferme après challenge sans ack »).
  4. Le client n'envoie JAMAIS de ClientHello DTLS (16 fe fd). Aucun record DTLS.
  5. Le paquet suivant du client (~18 o, ex. 80E82AC4…5F00000C) parse en clair comme un bunch bClose=true sur le canal de contrôle (ChIndex 0)le client FERME.

Interprétation : après NMT_EncryptionAck, le client attend que les paquets serveur suivants soient chiffrés ; on lui renvoie le Challenge en clair → il abandonne. Donc l'EncryptionAck seul ne suffit pas : il faut réellement chiffrer la voie serveur après l'ack (le mur crypto de fond, inchangé). Activer notre composant DTLS ne sert à rien tant que le client ne fait pas de handshake DTLS de son côté — piste à creuser : est-ce de l'AES-GCM keyé par la PSK (SetEncryptionData/EnableEncryption d'UE) plutôt que du DTLS-handshake ?

Bug serveur concret trouvé : UChannel.ConditionalCleanUp (Prospect.Unreal, ~l.822) = throw new NotImplementedException()crash du tick dès qu'un canal se ferme (bClose). À implémenter (indépendant du crypto).

Acquis réutilisables : on passe le handshake + le Hello + l'EncryptionAck est accepté ; la PSK par user_id est branchée bout-en-bout (Vault → env → serveur). Le blocage net = chiffrement de la voie serveur post-ack.