gameserver: consolidate netcode R&D — remove diag spam, document DTLS-PSK wall
Build Game Server / build (push) Successful in 21s
Build Game Server / build (push) Successful in 21s
- Keep the R3.5.0 header-bit fix (bunch alignment breakthrough) - Remove per-packet [HS-RAW]/[HS-HDR]/[HS-BUNCH] diagnostics - Document the DTLS-PSK encryption wall + packed-binary finding in NETCODE-RND.md - UWorld NMT_Hello: honest comment on why the connection stops here Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
# 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.
|
||||||
@@ -501,21 +501,6 @@ public abstract class UNetConnection : UPlayer
|
|||||||
var resetReaderMark = new FBitReaderMark(reader);
|
var resetReaderMark = new FBitReaderMark(reader);
|
||||||
var channelsToClose = new List<FChannelCloseInfo>();
|
var channelsToClose = new List<FChannelCloseInfo>();
|
||||||
|
|
||||||
// TEMP DIAG: dump the raw packet bit-stream (LSB-first per byte) so we can
|
|
||||||
// hand-decode the header/bunch alignment. Non-destructive (position restored).
|
|
||||||
{
|
|
||||||
var peekMark = new FBitReaderMark(reader);
|
|
||||||
var totalDiag = reader.GetBitsLeft();
|
|
||||||
var takeDiag = Math.Min(totalDiag, 512);
|
|
||||||
var sbDiag = new System.Text.StringBuilder(takeDiag);
|
|
||||||
for (var i = 0; i < takeDiag; i++)
|
|
||||||
{
|
|
||||||
sbDiag.Append(reader.ReadBit() ? '1' : '0');
|
|
||||||
}
|
|
||||||
peekMark.Pop(reader);
|
|
||||||
Logger.Information("[HS-RAW] totalBits={T} bits={B}", totalDiag, sbDiag.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_bInternalAck)
|
if (_bInternalAck)
|
||||||
{
|
{
|
||||||
++InPacketId;
|
++InPacketId;
|
||||||
@@ -569,9 +554,6 @@ public abstract class UNetConnection : UPlayer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.Information("[HS-HDR] seq={Seq} ackedSeq={Acked} historyWords={HW} posAfterHeader={Pos} bitsLeft={Left}",
|
|
||||||
header.Seq.Value, header.AckedSeq.Value, header.HistoryWordCount, reader.GetPosBits(), reader.GetBitsLeft());
|
|
||||||
|
|
||||||
var packetSequenceDelta = PacketNotify.GetSequenceDelta(header);
|
var packetSequenceDelta = PacketNotify.GetSequenceDelta(header);
|
||||||
|
|
||||||
// The Cycle client doesn't derive its initial packet sequences from the handshake
|
// The Cycle client doesn't derive its initial packet sequences from the handshake
|
||||||
@@ -686,9 +668,6 @@ public abstract class UNetConnection : UPlayer
|
|||||||
bunch.bIsReplicationPaused = reader.ReadBit();
|
bunch.bIsReplicationPaused = reader.ReadBit();
|
||||||
bunch.bReliable = reader.ReadBit();
|
bunch.bReliable = reader.ReadBit();
|
||||||
|
|
||||||
Logger.Information("[HS-BUNCH] startBit={Start} posBeforeChIndex={Pos} bitsLeft={Left} bControl={C} bOpen={O} bClose={Cl} bReliable={R} netVer={V}",
|
|
||||||
incomingStartPos, reader.GetPosBits(), reader.GetBitsLeft(), bControl, bunch.bOpen, bunch.bClose, bunch.bReliable, (int)bunch.EngineNetVer());
|
|
||||||
|
|
||||||
if (bunch.EngineNetVer() < EEngineNetworkVersionHistory.HISTORY_MAX_ACTOR_CHANNELS_CUSTOMIZATION)
|
if (bunch.EngineNetVer() < EEngineNetworkVersionHistory.HISTORY_MAX_ACTOR_CHANNELS_CUSTOMIZATION)
|
||||||
{
|
{
|
||||||
const int oldMaxActorChannels = 10240;
|
const int oldMaxActorChannels = 10240;
|
||||||
|
|||||||
@@ -286,15 +286,28 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// The Cycle client offers an EncryptionToken in NMT_Hello. A UE
|
// R&D WALL — The Cycle mandates encryption on the game connection.
|
||||||
// connection only switches to AES once the server replies with
|
// The client's NMT_Hello carries EncryptionToken = its PlayFab
|
||||||
// NMT_EncryptionAck — the token is an offer, not a requirement.
|
// user_id (e.g. "92EBCFE8C3EAF3AC"), and the client PacketHandler
|
||||||
// Implementing the full AES-GCM packet handler is a large effort,
|
// stack is [DTLSHandlerComponent, StatelessConnectHandlerComponent]
|
||||||
// so first proceed WITHOUT encryption: never send the ack and go
|
// (confirmed from the client's own logs). Encryption is therefore
|
||||||
// straight to the challenge. If the client keeps talking plaintext
|
// DTLS in PSK mode (the shipping exe bundles ECDHE/DHE-PSK-AES256
|
||||||
// we're done; if it insists on encryption it will drop here and we
|
// cipher suites + a "DTLS.PreSharedKeys" cvar + DTLSPSK*Callback).
|
||||||
// know AES is mandatory.
|
//
|
||||||
Logger.Warning("Client sent EncryptionToken ({Len} chars) — proceeding WITHOUT encryption (no EncryptionAck)", encryptionToken.Length);
|
// Proceeding to the challenge WITHOUT sending NMT_EncryptionAck does
|
||||||
|
// NOT work: the client requires encryption and closes the control
|
||||||
|
// channel immediately after the plaintext challenge.
|
||||||
|
//
|
||||||
|
// To finish this we'd need (1) a DTLS-PSK server that matches UE's
|
||||||
|
// DTLSHandlerComponent framing, and (2) the 32-byte PSK the client
|
||||||
|
// derives per user_id. The derivation lives in the client's code,
|
||||||
|
// which cannot be recovered statically: the exe is packed/encrypted
|
||||||
|
// (.text entropy = 8.0), so Ghidra/radare2 see only ciphertext. The
|
||||||
|
// only route left is a runtime memory dump of the decrypted image.
|
||||||
|
//
|
||||||
|
// For now: log and proceed to the challenge so the flow is visible
|
||||||
|
// in logs; the client will close afterwards.
|
||||||
|
Logger.Warning("Client requires DTLS-PSK encryption (EncryptionToken={Token}); server-side DTLS not implemented — connection will be dropped by client", encryptionToken);
|
||||||
connection.SendChallengeControlMessage();
|
connection.SendChallengeControlMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user