feat: upgrade stream cipher from XOR to AES-128-CTR

Replace the XOR obfuscation cipher with AES-128-CTR using the Windows
BCrypt API. Key material grows from 16 to 32 bytes (16 AES key + 16 IV).
All callers auto-adjust via StreamCipher::KEY_SIZE. No handshake or
protocol changes needed beyond the larger MC|CKey payload.
This commit is contained in:
itsRevela 2026-03-28 20:10:35 -05:00
parent 1036c360dc
commit 245da783b3
6 changed files with 211 additions and 59 deletions

View file

@ -310,7 +310,7 @@ bool WinsockNetLayer::SendAckAndActivateClientSendCipher()
{ {
// Activate send cipher immediately after the ack is on the wire // Activate send cipher immediately after the ack is on the wire
EnterCriticalSection(&s_clientCipherLock); EnterCriticalSection(&s_clientCipherLock);
s_clientSendCipher.Initialize(s_clientPendingKey); s_clientSendCipher.Initialize(s_clientPendingKey, ServerRuntime::Security::StreamCipher::Client);
LeaveCriticalSection(&s_clientCipherLock); LeaveCriticalSection(&s_clientCipherLock);
app.DebugPrintf("Client: Send cipher activated (MC|CAck sent)\n"); app.DebugPrintf("Client: Send cipher activated (MC|CAck sent)\n");
} }
@ -329,7 +329,7 @@ bool WinsockNetLayer::SendAckAndActivateClientSendCipher()
void WinsockNetLayer::ActivateClientRecvCipher() void WinsockNetLayer::ActivateClientRecvCipher()
{ {
EnterCriticalSection(&s_clientCipherLock); EnterCriticalSection(&s_clientCipherLock);
s_clientRecvCipher.Initialize(s_clientPendingKey); s_clientRecvCipher.Initialize(s_clientPendingKey, ServerRuntime::Security::StreamCipher::Client);
SecureZeroMemory(s_clientPendingKey, sizeof(s_clientPendingKey)); SecureZeroMemory(s_clientPendingKey, sizeof(s_clientPendingKey));
s_clientKeyStored = false; s_clientKeyStored = false;
LeaveCriticalSection(&s_clientCipherLock); LeaveCriticalSection(&s_clientCipherLock);

View file

@ -1,6 +1,9 @@
#include "stdafx.h" #include "stdafx.h"
#include "IdentityTokenManager.h" #include "IdentityTokenManager.h"
#include "StreamCipher.h"
#ifdef _WINDOWS64
#include <bcrypt.h>
#endif
#include "..\Common\FileUtils.h" #include "..\Common\FileUtils.h"
#include "..\Common\StringUtils.h" #include "..\Common\StringUtils.h"
@ -136,15 +139,20 @@ namespace ServerRuntime
bool IdentityTokenManager::IssueToken(PlayerUID xuid, uint8_t outToken[TOKEN_SIZE]) bool IdentityTokenManager::IssueToken(PlayerUID xuid, uint8_t outToken[TOKEN_SIZE])
{ {
// Generate a random 32-byte token using two 16-byte CryptGenRandom calls // Generate a random 32-byte identity token
uint8_t token[TOKEN_SIZE]; uint8_t token[TOKEN_SIZE];
bool ok1 = StreamCipher::GenerateKey(token); #ifdef _WINDOWS64
bool ok2 = StreamCipher::GenerateKey(token + StreamCipher::KEY_SIZE); NTSTATUS status = BCryptGenRandom(nullptr, token, TOKEN_SIZE,
if (!ok1 || !ok2) BCRYPT_USE_SYSTEM_PREFERRED_RNG);
if (!BCRYPT_SUCCESS(status))
{ {
SecureZeroMemory(token, sizeof(token)); SecureZeroMemory(token, sizeof(token));
return false; return false;
} }
#else
for (int i = 0; i < TOKEN_SIZE; ++i)
token[i] = static_cast<uint8_t>(rand() & 0xFF);
#endif
EnterCriticalSection(&m_lock); EnterCriticalSection(&m_lock);
m_tokens[xuid] = std::vector<uint8_t>(token, token + TOKEN_SIZE); m_tokens[xuid] = std::vector<uint8_t>(token, token + TOKEN_SIZE);

View file

@ -2,9 +2,8 @@
#include "StreamCipher.h" #include "StreamCipher.h"
#ifdef _WINDOWS64 #ifdef _WINDOWS64
#include <Windows.h> #include <bcrypt.h>
#include <wincrypt.h> #pragma comment(lib, "bcrypt.lib")
#pragma comment(lib, "Advapi32.lib")
#endif #endif
#include <cstring> #include <cstring>
@ -14,29 +13,144 @@ namespace ServerRuntime
namespace Security namespace Security
{ {
StreamCipher::StreamCipher() StreamCipher::StreamCipher()
: m_sendPos(0) : m_sendKeystreamPos(AES_BLOCK)
, m_recvPos(0) , m_recvKeystreamPos(AES_BLOCK)
, m_active(false) , m_active(false)
{ {
memset(m_key, 0, sizeof(m_key)); #ifdef _WINDOWS64
m_hAlg = nullptr;
m_hKey = nullptr;
#endif
memset(m_sendCounter, 0, sizeof(m_sendCounter));
memset(m_recvCounter, 0, sizeof(m_recvCounter));
memset(m_sendKeystream, 0, sizeof(m_sendKeystream));
memset(m_recvKeystream, 0, sizeof(m_recvKeystream));
} }
void StreamCipher::Initialize(const uint8_t key[KEY_SIZE]) StreamCipher::~StreamCipher()
{ {
memcpy(m_key, key, KEY_SIZE); Reset();
m_sendPos = 0; }
m_recvPos = 0;
void StreamCipher::Initialize(const uint8_t key[KEY_SIZE], Role role)
{
if (m_active)
{
Reset();
}
#ifdef _WINDOWS64
NTSTATUS status;
status = BCryptOpenAlgorithmProvider(&m_hAlg, BCRYPT_AES_ALGORITHM, nullptr, 0);
if (!BCRYPT_SUCCESS(status))
{
m_hAlg = nullptr;
return;
}
// Set ECB mode -- we manage CTR ourselves for streaming support
status = BCryptSetProperty(m_hAlg, BCRYPT_CHAINING_MODE,
(PUCHAR)BCRYPT_CHAIN_MODE_ECB, sizeof(BCRYPT_CHAIN_MODE_ECB), 0);
if (!BCRYPT_SUCCESS(status))
{
BCryptCloseAlgorithmProvider(m_hAlg, 0);
m_hAlg = nullptr;
return;
}
// Create symmetric key from first 16 bytes
status = BCryptGenerateSymmetricKey(m_hAlg, &m_hKey, nullptr, 0,
(PUCHAR)key, AES_BLOCK, 0);
if (!BCRYPT_SUCCESS(status))
{
BCryptCloseAlgorithmProvider(m_hAlg, 0);
m_hAlg = nullptr;
m_hKey = nullptr;
return;
}
// Derive separate counters for send and recv to prevent CTR nonce reuse.
// Flipping the top bit of byte 0 guarantees the two counter spaces never
// overlap (one in 0x00-0x7F range, the other in 0x80-0xFF for byte 0).
// Server send = IV, Server recv = IV^0x80
// Client send = IV^0x80, Client recv = IV
// This ensures: server-send matches client-recv, client-send matches server-recv.
uint8_t ivBase[AES_BLOCK];
uint8_t ivFlipped[AES_BLOCK];
memcpy(ivBase, key + AES_BLOCK, AES_BLOCK);
memcpy(ivFlipped, key + AES_BLOCK, AES_BLOCK);
ivFlipped[0] ^= 0x80;
if (role == Server)
{
memcpy(m_sendCounter, ivBase, AES_BLOCK);
memcpy(m_recvCounter, ivFlipped, AES_BLOCK);
}
else
{
memcpy(m_sendCounter, ivFlipped, AES_BLOCK);
memcpy(m_recvCounter, ivBase, AES_BLOCK);
}
SecureZeroMemory(ivBase, sizeof(ivBase));
SecureZeroMemory(ivFlipped, sizeof(ivFlipped));
m_sendKeystreamPos = AES_BLOCK; // force generation on first use
m_recvKeystreamPos = AES_BLOCK;
m_active = true; m_active = true;
#endif
} }
void StreamCipher::Reset() void StreamCipher::Reset()
{ {
SecureZeroMemory(m_key, sizeof(m_key)); #ifdef _WINDOWS64
m_sendPos = 0; if (m_hKey != nullptr)
m_recvPos = 0; {
BCryptDestroyKey(m_hKey);
m_hKey = nullptr;
}
if (m_hAlg != nullptr)
{
BCryptCloseAlgorithmProvider(m_hAlg, 0);
m_hAlg = nullptr;
}
#endif
SecureZeroMemory(m_sendCounter, sizeof(m_sendCounter));
SecureZeroMemory(m_recvCounter, sizeof(m_recvCounter));
SecureZeroMemory(m_sendKeystream, sizeof(m_sendKeystream));
SecureZeroMemory(m_recvKeystream, sizeof(m_recvKeystream));
m_sendKeystreamPos = AES_BLOCK;
m_recvKeystreamPos = AES_BLOCK;
m_active = false; m_active = false;
} }
void StreamCipher::IncrementCounter(uint8_t counter[AES_BLOCK])
{
// Big-endian 128-bit increment (standard NIST CTR convention)
for (int i = AES_BLOCK - 1; i >= 0; --i)
{
if (++counter[i] != 0)
break;
}
}
void StreamCipher::GenerateKeystream(uint8_t counter[AES_BLOCK], uint8_t keystream[AES_BLOCK])
{
#ifdef _WINDOWS64
ULONG cbResult = 0;
NTSTATUS status = BCryptEncrypt(m_hKey, counter, AES_BLOCK, nullptr,
nullptr, 0, keystream, AES_BLOCK, &cbResult, 0); // flags=0: exact block, no padding
if (!BCRYPT_SUCCESS(status))
{
SecureZeroMemory(keystream, AES_BLOCK);
m_active = false;
return;
}
IncrementCounter(counter);
#endif
}
void StreamCipher::Encrypt(uint8_t *data, int length) void StreamCipher::Encrypt(uint8_t *data, int length)
{ {
if (!m_active || data == nullptr || length <= 0) if (!m_active || data == nullptr || length <= 0)
@ -46,8 +160,12 @@ namespace ServerRuntime
for (int i = 0; i < length; ++i) for (int i = 0; i < length; ++i)
{ {
data[i] ^= m_key[m_sendPos]; if (m_sendKeystreamPos >= AES_BLOCK)
m_sendPos = (m_sendPos + 1) % KEY_SIZE; {
GenerateKeystream(m_sendCounter, m_sendKeystream);
m_sendKeystreamPos = 0;
}
data[i] ^= m_sendKeystream[m_sendKeystreamPos++];
} }
} }
@ -60,25 +178,23 @@ namespace ServerRuntime
for (int i = 0; i < length; ++i) for (int i = 0; i < length; ++i)
{ {
data[i] ^= m_key[m_recvPos]; if (m_recvKeystreamPos >= AES_BLOCK)
m_recvPos = (m_recvPos + 1) % KEY_SIZE; {
GenerateKeystream(m_recvCounter, m_recvKeystream);
m_recvKeystreamPos = 0;
}
data[i] ^= m_recvKeystream[m_recvKeystreamPos++];
} }
} }
bool StreamCipher::GenerateKey(uint8_t outKey[KEY_SIZE]) bool StreamCipher::GenerateKey(uint8_t outKey[KEY_SIZE])
{ {
#ifdef _WINDOWS64 #ifdef _WINDOWS64
HCRYPTPROV hProv = 0; NTSTATUS status = BCryptGenRandom(nullptr, outKey, KEY_SIZE,
if (!CryptAcquireContext(&hProv, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) BCRYPT_USE_SYSTEM_PREFERRED_RNG);
{ return BCRYPT_SUCCESS(status);
return false;
}
BOOL result = CryptGenRandom(hProv, KEY_SIZE, outKey);
CryptReleaseContext(hProv, 0);
return result != FALSE;
#else #else
// Fallback: not cryptographically random, but better than nothing // Fallback: not cryptographically random
for (int i = 0; i < KEY_SIZE; ++i) for (int i = 0; i < KEY_SIZE; ++i)
{ {
outKey[i] = static_cast<uint8_t>(rand() & 0xFF); outKey[i] = static_cast<uint8_t>(rand() & 0xFF);

View file

@ -2,68 +2,96 @@
#include <cstdint> #include <cstdint>
#ifdef _WINDOWS64
#include <Windows.h>
#include <bcrypt.h>
#endif
namespace ServerRuntime namespace ServerRuntime
{ {
namespace Security namespace Security
{ {
/** /**
* Lightweight XOR stream cipher for traffic obfuscation. * AES-128-CTR stream cipher for game traffic encryption.
* *
* This is NOT cryptographically secure. It prevents passive packet sniffing * Uses the Windows BCrypt API to generate AES-encrypted keystream
* (e.g., Wireshark-based XUID harvesting) but does not protect against * blocks that are XOR'd with plaintext. Each direction (send/recv)
* active man-in-the-middle attacks. For real encryption, use TLS via a * maintains its own counter for independent keystream generation.
* reverse proxy (stunnel, nginx stream). *
* Key material: 32 bytes (16-byte AES key + 16-byte IV/nonce).
* The IV is used as the initial counter block for both directions.
* *
* Usage: * Usage:
* 1. Server generates a random 16-byte key during PreLogin handshake * 1. Server generates a random 32-byte key via GenerateKey()
* 2. Key is sent to the client (in a SecurityHandshakePacket) * 2. Key is sent to the client in the MC|CKey CustomPayloadPacket
* 3. Both sides create a StreamCipher with the same key * 3. Both sides call Initialize() with the same 32 bytes
* 4. All subsequent TCP traffic is XOR'd through the cipher * 4. All subsequent TCP traffic is encrypted via Encrypt/Decrypt
* 5. The cipher maintains separate send/recv rolling key positions
*/ */
class StreamCipher class StreamCipher
{ {
public: public:
static const int KEY_SIZE = 16; static const int KEY_SIZE = 32; // 16 AES key + 16 IV
enum Role { Server, Client };
StreamCipher(); StreamCipher();
~StreamCipher();
StreamCipher(const StreamCipher &) = delete;
StreamCipher &operator=(const StreamCipher &) = delete;
StreamCipher(StreamCipher &&) = delete;
StreamCipher &operator=(StreamCipher &&) = delete;
/** /**
* Initialize with a key. Call before any encrypt/decrypt. * Initialize with key material. First 16 bytes = AES key, last 16 bytes = IV.
* Role determines counter assignment to prevent nonce reuse between directions:
* Server: send=IV, recv=IV^0x80 (top bit flipped)
* Client: send=IV^0x80, recv=IV
* This ensures server-send matches client-recv and vice versa.
*/ */
void Initialize(const uint8_t key[KEY_SIZE]); void Initialize(const uint8_t key[KEY_SIZE], Role role = Server);
/** /**
* XOR-encrypt data in place for sending. * AES-CTR encrypt data in place for sending.
* Advances the send key position.
*/ */
void Encrypt(uint8_t *data, int length); void Encrypt(uint8_t *data, int length);
/** /**
* XOR-decrypt data in place after receiving. * AES-CTR decrypt data in place after receiving.
* Advances the recv key position.
*/ */
void Decrypt(uint8_t *data, int length); void Decrypt(uint8_t *data, int length);
/** /**
* Returns true if the cipher has been initialized with a key. * Returns true if the cipher has been initialized.
*/ */
bool IsActive() const { return m_active; } bool IsActive() const { return m_active; }
/** /**
* Reset to inactive state and securely wipe key material. * Reset to inactive state and securely wipe all key material.
*/ */
void Reset(); void Reset();
/** /**
* Generates a cryptographically random key using CryptGenRandom (Windows). * Generate 32 cryptographically random bytes (16 AES key + 16 IV).
*/ */
static bool GenerateKey(uint8_t outKey[KEY_SIZE]); static bool GenerateKey(uint8_t outKey[KEY_SIZE]);
private: private:
uint8_t m_key[KEY_SIZE]; static const int AES_BLOCK = 16;
int m_sendPos;
int m_recvPos; static void IncrementCounter(uint8_t counter[AES_BLOCK]);
void GenerateKeystream(uint8_t counter[AES_BLOCK], uint8_t keystream[AES_BLOCK]);
#ifdef _WINDOWS64
BCRYPT_ALG_HANDLE m_hAlg;
BCRYPT_KEY_HANDLE m_hKey;
#endif
uint8_t m_sendCounter[AES_BLOCK];
uint8_t m_recvCounter[AES_BLOCK];
uint8_t m_sendKeystream[AES_BLOCK];
uint8_t m_recvKeystream[AES_BLOCK];
int m_sendKeystreamPos;
int m_recvKeystreamPos;
bool m_active; bool m_active;
}; };
} }

View file

@ -18,7 +18,7 @@ public:
static const wstring SET_ITEM_NAME_PACKET; static const wstring SET_ITEM_NAME_PACKET;
// Security: stream cipher handshake channels // Security: stream cipher handshake channels
static const wstring CIPHER_KEY_CHANNEL; // server->client: carries 16-byte key static const wstring CIPHER_KEY_CHANNEL; // server->client: carries 32-byte key (16 AES key + 16 IV)
static const wstring CIPHER_ACK_CHANNEL; // client->server: ack (empty payload) static const wstring CIPHER_ACK_CHANNEL; // client->server: ack (empty payload)
static const wstring CIPHER_ON_CHANNEL; // server->client: activation signal (empty payload) static const wstring CIPHER_ON_CHANNEL; // server->client: activation signal (empty payload)

View file

@ -30,7 +30,7 @@ The dedicated server now includes a comprehensive security system to protect aga
| Key | Default | Description | | Key | Default | Description |
|-----|---------|-------------| |-----|---------|-------------|
| `enable-stream-cipher` | `true` | Encrypt all game traffic with a per-session stream cipher | | `enable-stream-cipher` | `true` | Encrypt all game traffic with AES-128-CTR |
| `require-secure-client` | `true` | Kick clients that don't complete the cipher handshake (blocks old clients) | | `require-secure-client` | `true` | Kick clients that don't complete the cipher handshake (blocks old clients) |
| `require-challenge-token` | `false` | Require identity token verification to prevent XUID impersonation | | `require-challenge-token` | `false` | Require identity token verification to prevent XUID impersonation |
| `proxy-protocol` | `false` | Parse PROXY protocol v1 headers for real client IPs behind a tunnel | | `proxy-protocol` | `false` | Parse PROXY protocol v1 headers for real client IPs behind a tunnel |