Files
the-cycle/NETCODE-RND.md

14 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.

MAJ (2026-07-16, nuit) — tentative AES-256-GCM keyé par la PSK

Implémenté AesGcmHandlerComponent (System.Security.Cryptography.AesGcm, format [IV 12o][ciphertext][tag 16o]), activé après l'EncryptionAck (ack en clair → activation → Challenge chiffré). Corrigé aussi l'ordre du pipeline : Incoming doit itérer en sens INVERSE d'Outgoing (PacketHandler.Incoming_Internal) — sinon, avec 2 composants actifs (stateless + AES), le stateless tente de parser des octets chiffrés. (Les deux étaient en avant ; n'avait jamais compté car un seul composant actif jusqu'ici.)

Observé (test live) : notre Challenge part bien chiffré ([AES] Outgoing 30o→58o), mais le client répond toujours par un paquet plaintext de ~18 o de structure constante (…41 00 XX FF 5F 00 00 0C) = un bClose du canal de contrôle, jamais rien qui ressemble à de l'AES (haute entropie ≥28o). Le client ne chiffre jamais son côté.

Conclusion : quelle que soit notre réponse au Hello (challenge clair, chiffré AES, ou tentative DTLS), le client ferme systématiquement le canal de contrôle en clair juste après. L'AES-GCM tel qu'implémenté ne le fait pas basculer en chiffré. Pistes restantes (deep RE, non tranchées) : (a) framing AES-GCM exact d'UE (schéma d'IV/nonce, AAD, position) ≠ notre IV aléatoire préfixé ; (b) c'est bien du DTLS-handshake et le client attend un ServerHello DTLS ; (c) notre réponse au Hello est incomplète (ordre/contenu des NMT) indépendamment du chiffrement. Chaque test = ~4 min (CI + redeploy). Bugs serveur secondaires à corriger : ConditionalCleanUp (stub) + ArgumentOutOfRangeException quand on vide un paquet trop court.

Décision : checkpoint. Tout est commité/documenté. La suite = RE netcode profonde multi-session ; et même résolue, ce n'est que le login — la simulation gameplay reste hors de portée réaliste.

Flux de chiffrement DTLS d'UE 4.27 (recherche, 2026-07-17)

Sources : KB Epic « Enable Encryption via Packet Handler Components », doc API (EnableEncryptionServer/SetEncryptionData/FEncryptionData), UE-95508 (ack chiffré si renvoyé), UE-171638 (module DTLS exige OpenSSL), logs DTLS/PSK réels (Satisfactory #453). Le source du plugin DTLS est gated (points reconstruits signalés).

Corrections clés :

  • Le CLIENT envoie le ClientHello ; le serveur ATTEND (accept), ne parle jamais en premier. Donc l'AES-GCM autonome = impasse (c'est bien du DTLS, records 16 fe fd standard OpenSSL).
  • NMT_EncryptionAck ne porte AUCUNE clé. À sa réception, le client appelle ReceivedNetworkEncryptionAckrésout lui-même sa FEncryptionData (Key + Identifier) → appelle EnableEncryption côté clientc'est CE qui déclenche son ClientHello.
  • Séquence serveur : SetEncryptionData(Key,Identifier)NMT_EncryptionAck en clair, sans payloadpuis EnableEncryption (mode accept). Ne jamais chiffrer l'ack.
  • Le NMT_Challenge doit être DIFFÉRÉ : le handler DTLS met les bunches applicatifs en file jusqu'à Handshaking completed. (Notre impl l'envoyait tout de suite → à corriger.)
  • PSK : PSK = FEncryptionData.Key, identité PSK = FEncryptionData.Identifier ; ressortie via DTLSPSKServerCallback (hook OpenSSL) quand le ClientHello arrive. Le serveur n'envoie jamais la clé.
  • Couches : DTLS sous le stateless (entrée : retirer stateless puis déchiffrer DTLS ; Outgoing forward / Incoming reverse).

Si le client ferme SANS ClientHello (notre cas) → cause la plus probable = échec de résolution de clé côté client (le vrai client shipping attend peut-être la clé de SON backend/matchmaking, non émulé — ou dérivation/Identifier différents), pas notre framing DTLS. C'est potentiellement un mur dur (dépend du backend réel du client).

Test informé RÉALISÉ (2026-07-17) : EncryptionAck seul (clair, sans payload) + DTLS accept + PAS de Challenge. Résultat : le client n'envoie TOUJOURS pas de ClientHello (zéro 16 fe fd dans les datagrammes bruts) et ferme avec le bClose plaintext habituel.

VERDICT (mur confirmé) — résolution de clé CÔTÉ CLIENT

Le blocage n'est pas notre framing serveur (DTLS ou AES) : le client n'entame jamais son handshake DTLS. Séquence définitive observée, quel que soit ce qu'on renvoie : stateless OK → NMT_Hello (clair) → notre NMT_EncryptionAck → client ferme (bClose), pas de ClientHello.

D'après la recherche du flux UE : le ClientHello n'est émis que si le client, dans ReceivedNetworkEncryptionAck, résout une FEncryptionData valide et appelle EnableEncryption côté client. Ici il ne le fait pas → il ferme. On ne peut pas forcer ça depuis le serveur.

Cause la plus probable : le vrai client shipping attend sa clé/identité de SON backend (réponse matchmaking/PlayFab), non émulée — ou une dérivation/Identifier qu'on ne fournit pas au bon endroit. La PSK a été récupérée (dump mémoire) mais ça ne suffit pas : c'est la décision client d'activer le chiffrement qui manque, et sa logique vit dans le binaire packé.

Pour aller plus loin il faudrait : RE de la fonction ReceivedNetworkEncryptionAck/résolution de clé du client (dans le .text déchiffré, même méthode que pour la PSK) pour savoir d'où il attend sa clé, puis la lui fournir (probablement côté backend rd2). Chantier RE profond, incertain.

Statut : mur dur, checkpoint. Acquis solides et réutilisables : handshake + Hello + EncryptionAck ; serveur DTLS-PSK ET AES-GCM implémentés ; ordre pipeline corrigé (Incoming inverse d'Outgoing) ; flux UE documenté. Le verrou restant dépend du client, pas du serveur.