feat: dedicated server security hardening

Comprehensive security system to protect against packet-sniffing attacks,
XUID harvesting, privilege escalation, bot flooding, and XUID impersonation.

- Stream cipher: per-session XOR cipher with 4-message handshake via
  CustomPayloadPacket (MC|CKey, MC|CAck, MC|COn). Negotiated per-connection,
  backwards compatible (old clients/servers fall back to plaintext).
- Security gate: buffers all game data until cipher handshake completes,
  preventing unsecured clients from receiving any XUIDs or game state.
- Cipher handshake enforcer: kicks clients that don't complete the handshake
  within 5 seconds (configurable via require-secure-client).
- Identity tokens: persistent per-XUID tokens in identity-tokens.json,
  issued over the encrypted channel, verified on reconnect. Prevents XUID
  replay attacks. Client stores server-specific tokens.
- PROXY protocol v1: parses real client IPs from playit.gg tunnel headers
  so rate limiting, IP bans, and XUID spoof detection work per-player.
- Rate limiting: per-IP sliding window (default 5 connections/30s) with
  pending connection cap (default 10).
- Privilege hardening: OP requires ops.json, live checks on every command
  and privilege packet. Host-only server settings changes.
- XUID stripping: PreLoginPacket response sends INVALID_XUID placeholders.
- Packet validation: readUtf global string cap, reduced max packet size,
  stream desync protection on oversized strings.
- OpManager: persistent ops.json with XUID-based OP list.
- Whitelist improvements: whitelist add accepts player names with ambiguity
  detection, XUID cache from login attempts.
- revoketoken command: revoke identity tokens for players who lost theirs.
- server.log: persistent log file written alongside console output with
  flush-per-write to survive crashes.
- CLI security logging: consolidated per-join security summary with cipher
  status, token status, XUID, and real IP. Security warnings for kicks,
  spoofing, and unauthorized commands.
This commit is contained in:
itsRevela
2026-03-28 19:18:06 -05:00
parent ed3fffcc6a
commit ba3ebe666c
42 changed files with 3293 additions and 34 deletions

View File

@@ -58,6 +58,7 @@
#ifdef _WINDOWS64
#include "Xbox\Network\NetworkPlayerXbox.h"
#include "Common\Network\PlatformNetworkManagerStub.h"
#include "Windows64\Network\WinsockNetLayer.h"
#endif
@@ -3787,6 +3788,120 @@ void ClientConnection::handleSoundEvent(shared_ptr<LevelSoundPacket> packet)
void ClientConnection::handleCustomPayload(shared_ptr<CustomPayloadPacket> customPayloadPacket)
{
#ifdef _WINDOWS64
// Build a server-specific identity token file path next to the executable.
// Each server gets its own token file based on a hash of the server address,
// so connecting to multiple secured servers doesn't overwrite tokens.
auto buildIdentityTokenPath = []() -> std::string {
char exePath[MAX_PATH] = {};
DWORD len = GetModuleFileNameA(NULL, exePath, MAX_PATH);
if (len == 0 || len >= MAX_PATH) return std::string();
char *lastSlash = strrchr(exePath, '\\');
if (lastSlash != NULL) *(lastSlash + 1) = 0;
// Hash the server IP:port to create a unique filename per server
char serverAddr[300] = {};
sprintf_s(serverAddr, sizeof(serverAddr), "%s:%d", g_Win64MultiplayerIP, g_Win64MultiplayerPort);
unsigned int hash = 5381;
for (const char *p = serverAddr; *p; ++p)
hash = ((hash << 5) + hash) + static_cast<unsigned char>(*p);
char filename[64] = {};
sprintf_s(filename, sizeof(filename), "identity-token-%08x.dat", hash);
return std::string(exePath) + filename;
};
// Identity token: server issued us a new token - store it locally
if (CustomPayloadPacket::IDENTITY_TOKEN_ISSUE.compare(customPayloadPacket->identifier) == 0)
{
if (customPayloadPacket->data.data != nullptr && customPayloadPacket->length == 32)
{
std::string tokenPath = buildIdentityTokenPath();
if (!tokenPath.empty())
{
FILE *f = nullptr;
fopen_s(&f, tokenPath.c_str(), "wb");
if (f != nullptr)
{
size_t written = fwrite(customPayloadPacket->data.data, 1, 32, f);
fclose(f);
if (written == 32)
{
app.DebugPrintf("Client: Stored identity token to %s\n", tokenPath.c_str());
}
else
{
app.DebugPrintf("Client: Failed to write full identity token (wrote %zu/32)\n", written);
}
}
else
{
app.DebugPrintf("Client: Failed to open %s for writing\n", tokenPath.c_str());
}
}
}
return;
}
// Identity token: server is challenging us to present our stored token
if (CustomPayloadPacket::IDENTITY_TOKEN_CHALLENGE.compare(customPayloadPacket->identifier) == 0)
{
std::string tokenPath = buildIdentityTokenPath();
FILE *f = nullptr;
if (!tokenPath.empty())
fopen_s(&f, tokenPath.c_str(), "rb");
if (f != nullptr)
{
uint8_t token[32] = {};
size_t bytesRead = fread(token, 1, 32, f);
fclose(f);
if (bytesRead == 32)
{
byteArray tokenData(32);
memcpy(tokenData.data, token, 32);
connection->send(std::make_shared<CustomPayloadPacket>(
CustomPayloadPacket::IDENTITY_TOKEN_RESPONSE, tokenData));
app.DebugPrintf("Client: Sent identity token response\n");
}
else
{
app.DebugPrintf("Client: identity-token.dat is invalid (%zu bytes)\n", bytesRead);
connection->send(std::make_shared<CustomPayloadPacket>(
CustomPayloadPacket::IDENTITY_TOKEN_RESPONSE, byteArray()));
}
SecureZeroMemory(token, sizeof(token));
}
else
{
app.DebugPrintf("Client: No identity-token.dat found, sending empty response\n");
connection->send(std::make_shared<CustomPayloadPacket>(
CustomPayloadPacket::IDENTITY_TOKEN_RESPONSE, byteArray()));
}
return;
}
// Stream cipher handshake: server sent us a key
if (CustomPayloadPacket::CIPHER_KEY_CHANNEL.compare(customPayloadPacket->identifier) == 0)
{
if (customPayloadPacket->length == ServerRuntime::Security::StreamCipher::KEY_SIZE &&
customPayloadPacket->data.data != nullptr)
{
app.DebugPrintf("Client: Received MC|CKey from server (%d bytes)\n", customPayloadPacket->length);
// Store key and send ack+activate atomically to prevent ResetClientCipher race
WinsockNetLayer::StoreClientCipherKey(customPayloadPacket->data.data);
if (!WinsockNetLayer::SendAckAndActivateClientSendCipher())
{
app.DebugPrintf("Client: Failed to send cipher ack, connection will be closed\n");
}
}
else
{
app.DebugPrintf("Client: Received malformed MC|CKey (length=%d)\n", customPayloadPacket->length);
}
return;
}
#endif
if (CustomPayloadPacket::TRADER_LIST_PACKET.compare(customPayloadPacket->identifier) == 0)
{
ByteArrayInputStream bais(customPayloadPacket->data);