Build Game Server / build (push) Successful in 23s
Recherche du flux d'encryption UE 4.27 : le client envoie le ClientHello (serveur en accept), NMT_EncryptionAck sans payload en clair déclenche la résolution de clé côté client puis son EnableEncryption. On active DTLS en accept et on NE PAS envoie le Challenge (différé jusqu'à Handshaking completed). Findings détaillés dans NETCODE-RND.
197 lines
12 KiB
Markdown
197 lines
12 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é en cours** : `EncryptionAck` seul (clair, sans payload) + DTLS **accept** (attente) +
|
|
**PAS de Challenge** → observer si le client envoie enfin un ClientHello `16 fe fd`. Si oui → progrès ;
|
|
si non → mur « résolution de clé côté client ».
|