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

@@ -43,7 +43,13 @@
#include "..\Minecraft.Server\ServerLogger.h"
#include "..\Minecraft.Server\ServerLogManager.h"
#include "..\Minecraft.Server\ServerProperties.h"
#include "..\Minecraft.Server\Security\SecurityConfig.h"
#include "..\Minecraft.Server\Security\ConnectionCipher.h"
#include "..\Minecraft.Server\Security\CipherHandshakeEnforcer.h"
#include "..\Minecraft.Server\Security\IdentityTokenManager.h"
extern bool g_Win64DedicatedServer;
static unsigned int s_playerListTickCount = 0;
static const int kIdentityResponseGraceTicks = 200; // 10 seconds at 20 TPS
#endif
// 4J - this class is fairly substantially altered as there didn't seem any point in porting code for banning, whitelisting, ops etc.
@@ -267,6 +273,22 @@ bool PlayerList::placeNewPlayer(Connection *connection, shared_ptr<ServerPlayer>
app.DebugPrintf("RECONNECT: placeNewPlayer smallId=%d entityId=%d dim=%d\n",
newSmallId, player->entityId, level->dimension->id);
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
// Close the security gate before sending any game data. All packets will be
// buffered until the cipher handshake completes, preventing unsecured clients
// from receiving XUIDs or game state during the grace period.
if (g_Win64DedicatedServer &&
ServerRuntime::Security::GetSettings().enableStreamCipher &&
ServerRuntime::Security::GetSettings().requireSecureClient)
{
INetworkPlayer *gateNp = connection->getSocket() ? connection->getSocket()->getPlayer() : nullptr;
if (gateNp != nullptr && !gateNp->IsLocal())
{
playerConnection->m_securityGateOpen = false;
}
}
#endif
playerConnection->send(std::make_shared<LoginPacket>(L"", player->entityId, level->getLevelData()->getGenerator(),
level->getSeed(),
player->gameMode->getGameModeForPlayer()->getId(),
@@ -338,6 +360,39 @@ bool PlayerList::placeNewPlayer(Connection *connection, shared_ptr<ServerPlayer>
}
}
}
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
// Initiate stream cipher handshake if enabled.
// Send MC|CKey with the generated key. Old clients will ignore the unknown channel.
if (g_Win64DedicatedServer && ServerRuntime::Security::GetSettings().enableStreamCipher)
{
BYTE smallId = 0;
Socket *cipherSock = connection->getSocket();
INetworkPlayer *cipherNp = cipherSock ? cipherSock->getPlayer() : nullptr;
if (cipherNp != nullptr && !cipherNp->IsLocal())
{
smallId = cipherNp->GetSmallId();
uint8_t key[ServerRuntime::Security::StreamCipher::KEY_SIZE];
if (ServerRuntime::Security::GetCipherRegistry().PrepareKey(smallId, key))
{
byteArray keyData(ServerRuntime::Security::StreamCipher::KEY_SIZE);
memcpy(keyData.data, key, ServerRuntime::Security::StreamCipher::KEY_SIZE);
playerConnection->send(std::make_shared<CustomPayloadPacket>(
CustomPayloadPacket::CIPHER_KEY_CHANNEL, keyData));
SecureZeroMemory(key, sizeof(key));
app.DebugPrintf("Server: Sent MC|CKey to player %ls (smallId=%d)\n",
player->getName().c_str(), smallId);
// Register with enforcer for timeout tracking
if (ServerRuntime::Security::GetSettings().requireSecureClient)
{
ServerRuntime::Security::GetHandshakeEnforcer().OnCipherKeySent(smallId, s_playerListTickCount);
}
}
}
}
#endif
return true;
}
@@ -570,6 +625,16 @@ void PlayerList::move(shared_ptr<ServerPlayer> player)
void PlayerList::remove(shared_ptr<ServerPlayer> player)
{
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
if (g_Win64DedicatedServer && player->connection != nullptr)
{
INetworkPlayer *np = player->connection->getNetworkPlayer();
if (np != nullptr)
{
ServerRuntime::Security::GetHandshakeEnforcer().OnDisconnected(np->GetSmallId());
}
}
#endif
save(player);
//4J Stu - We don't want to save the map data for guests, so when we are sure that the player is gone delete the map
if(player->isGuest()) playerIo->deleteMapFilesForPlayer(player);
@@ -1038,6 +1103,131 @@ void PlayerList::repositionAcrossDimension(shared_ptr<Entity> entity, int lastDi
void PlayerList::tick()
{
#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD)
++s_playerListTickCount;
// Cipher handshake enforcement: kick clients that haven't completed the handshake
if (g_Win64DedicatedServer &&
ServerRuntime::Security::GetSettings().enableStreamCipher &&
ServerRuntime::Security::GetSettings().requireSecureClient)
{
std::vector<unsigned char> expired;
std::vector<unsigned char> completed;
ServerRuntime::Security::GetHandshakeEnforcer().CheckTimeouts(s_playerListTickCount, expired, completed);
for (unsigned char smallId : expired)
{
app.DebugPrintf("SECURITY: Kicking unsecured client (smallId=%d) - cipher handshake timed out\n", smallId);
ServerRuntime::ServerLogManager::OnUnsecuredClientKicked(smallId);
EnterCriticalSection(&m_closePlayersCS);
m_smallIdsToClose.push_back(smallId);
LeaveCriticalSection(&m_closePlayersCS);
}
// Report cipher completion and open security gate for all completed handshakes
for (unsigned char smallId : completed)
{
// Open the security gate -- flush buffered game packets now that cipher is active
for (auto &p : players)
{
if (p == nullptr || p->connection == nullptr) continue;
INetworkPlayer *np = p->connection->getNetworkPlayer();
if (np != nullptr && np->GetSmallId() == smallId)
{
if (!p->connection->isSecurityGateOpen())
{
p->connection->openSecurityGate();
}
break;
}
}
if (ServerRuntime::Security::GetSettings().requireChallengeToken)
{
ServerRuntime::ServerLogManager::OnCipherHandshakeCompleted(smallId);
}
else
{
ServerRuntime::ServerLogManager::OnCipherCompletedNoTokenRequired(smallId);
}
}
// For newly-completed cipher handshakes, initiate identity token exchange
if (ServerRuntime::Security::GetSettings().requireChallengeToken)
{
for (unsigned char smallId : completed)
{
// Find the player by smallId
for (auto &p : players)
{
if (p == nullptr || p->connection == nullptr) continue;
INetworkPlayer *np = p->connection->getNetworkPlayer();
if (np == nullptr || np->GetSmallId() != smallId) continue;
PlayerUID xuid = p->connection->m_offlineXUID;
if (xuid == INVALID_XUID) xuid = p->connection->m_onlineXUID;
if (p->connection->getIdentityChallengeTick() >= 0)
{
// Already challenged, skip
}
else if (ServerRuntime::Security::GetIdentityTokenManager().HasToken(xuid))
{
// Returning player - challenge them
p->connection->send(std::make_shared<CustomPayloadPacket>(
CustomPayloadPacket::IDENTITY_TOKEN_CHALLENGE, byteArray()));
p->connection->setIdentityChallengeTick(s_playerListTickCount);
app.DebugPrintf("Server: Sent identity challenge to %ls (smallId=%d)\n",
p->getName().c_str(), smallId);
}
else
{
// New player - issue a token over the encrypted channel
uint8_t token[ServerRuntime::Security::IdentityTokenManager::TOKEN_SIZE];
if (ServerRuntime::Security::GetIdentityTokenManager().IssueToken(xuid, token))
{
byteArray tokenData(ServerRuntime::Security::IdentityTokenManager::TOKEN_SIZE);
memcpy(tokenData.data, token, ServerRuntime::Security::IdentityTokenManager::TOKEN_SIZE);
p->connection->send(std::make_shared<CustomPayloadPacket>(
CustomPayloadPacket::IDENTITY_TOKEN_ISSUE, tokenData));
SecureZeroMemory(token, sizeof(token));
p->connection->setIdentityVerified(true);
app.DebugPrintf("Server: Issued identity token to %ls (smallId=%d)\n",
p->getName().c_str(), smallId);
ServerRuntime::ServerLogManager::OnIdentityTokenIssued(smallId);
}
}
break;
}
}
// Enforce identity token response timeout
for (auto &p : players)
{
if (p == nullptr || p->connection == nullptr) continue;
int challengeTick = p->connection->getIdentityChallengeTick();
if (challengeTick >= 0 && !p->connection->isIdentityVerified() &&
(s_playerListTickCount - challengeTick) > kIdentityResponseGraceTicks)
{
app.DebugPrintf("SECURITY: Kicking %ls - identity token response timed out\n",
p->getName().c_str());
INetworkPlayer *npLog = p->connection->getNetworkPlayer();
if (npLog != nullptr)
ServerRuntime::ServerLogManager::OnIdentityTokenTimeout(npLog->GetSmallId(), p->getName());
p->connection->setIdentityChallengeTick(-1); // prevent re-queuing
INetworkPlayer *np = p->connection->getNetworkPlayer();
if (np != nullptr)
{
EnterCriticalSection(&m_closePlayersCS);
m_smallIdsToClose.push_back(np->GetSmallId());
LeaveCriticalSection(&m_closePlayersCS);
}
}
}
}
}
#endif
// 4J - brought changes to how often this is sent forward from 1.2.3
if (++sendAllPlayerInfoIn > SEND_PLAYER_INFO_INTERVAL)
{