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_HelloporteEncryptionToken= 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 cvarDTLS.PreSharedKeys, etDTLSPSKClientCallback/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/<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.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 ; leelsedu blocNMT.Hellodocumente 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
.textdéchiffré du client via/proc/<pid>/memsous Proton — BattlEye n'a pas de driver kernel sous Linux). Stockée dans Vault (secret/the-cycle, cléGAMESERVER_DTLS_PSKS, formatuser_id:hex32), jamais dans le repo. - Serveur DTLS-PSK implémenté (BouncyCastle) :
DtlsPskStore(envPROSPECT_DTLS_PSKS),DtlsPacketTransport(pont bloquant→paquet),ProspectPskTlsServer(DTLS 1.2, identité=user_id),DTLSHandlerComponent(pipeline).UWorldsurNMT_Hellochiffré : PSK connue →NMT_EncryptionAck+ handshake.- Compile (0 erreur). Non validé en live.
- Reste (itération live obligatoire, invalidable hors client) :
- ordre exact du pipeline
[DTLS, Stateless]et routage des records de handshake (émis viaLowLevelSend— doivent porter le framing stateless attendu) ; - framing DTLS-sur-PacketHandler d'UE (record layer, cookie DTLS éventuel) ;
- bascule handshake→données applicatives.
Méthode : lancer
Prospect.Server.Gameavec la PSK, diriger le client dessus, itérer sur les logs serveur.
- ordre exact du pipeline
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) :
- Handshake stateless : ✅ complet (challenge/cookie/ack, connexion acceptée).
NMT_Helloreçu EN CLAIR avec EncryptionToken=92EBCFE8C3EAF3AC→ PSK reconnue.- 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 »). - Le client n'envoie JAMAIS de ClientHello DTLS (
16 fe fd). Aucun record DTLS. - Le paquet suivant du client (~18 o, ex.
80E82AC4…5F00000C) parse en clair comme un bunchbClose=truesur 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.