220 lines
14 KiB
Markdown
220 lines
14 KiB
Markdown
# 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` / `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 ;
|
|
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
|
|
`ReceivedNetworkEncryptionAck` → **résout lui-même** sa `FEncryptionData` (Key + Identifier)
|
|
→ appelle **`EnableEncryption` côté client** → **c'est CE qui déclenche son ClientHello**.
|
|
- **Séquence serveur** : `SetEncryptionData(Key,Identifier)` → `NMT_EncryptionAck` **en clair, sans
|
|
payload** → **puis** `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.
|