Initial commit: Ananta CBT 4229938 private server with all fixes
This commit is contained in:
commit
c84135c357
286 files changed
+2214315
No files matched your search
@@ -0,0 +1,3 @@
|
||||
namespace Ananta.SDK.Network;
|
||||
|
||||
public readonly record struct Frame(byte Mode, byte[] Payload);
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Ananta.SDK.Network;
|
||||
|
||||
public static class FrameCodec
|
||||
{
|
||||
// Public/plain overload retained for handshake/debug compatibility.
|
||||
public static Task<Frame?> ReadFrameAsync(NetworkStream stream, CancellationToken token)
|
||||
=> ReadFrameAsync(stream, null, token);
|
||||
|
||||
internal static async Task<Frame?> ReadFrameAsync(NetworkStream stream, UxChaCha8? cryptor, CancellationToken token)
|
||||
{
|
||||
// UXStreamSocket decrypts the first five bytes (length + mode) as one
|
||||
// continuous stream, then decrypts the payload with the SAME cryptor state.
|
||||
var head = new byte[5];
|
||||
if (!await ReadExactAsync(stream, head, token)) return null;
|
||||
var rawHead = Convert.ToHexString(head);
|
||||
cryptor?.XorInPlace(head);
|
||||
|
||||
var payloadLength = BinaryPrimitives.ReadInt32LittleEndian(head.AsSpan(0, 4));
|
||||
if (payloadLength < 0 || payloadLength > 8 * 1024 * 1024)
|
||||
{
|
||||
// Raw bytes kept on purpose: a frame sent before the peer installed
|
||||
// its cryptor arrives as plaintext and fails this check.
|
||||
Console.Error.WriteLine($"[FRAME-DBG] rawHead={rawHead} decryptedLen={payloadLength} mode={head[4]}");
|
||||
throw new InvalidDataException($"Bad frame payload length after decrypt: {payloadLength}");
|
||||
}
|
||||
|
||||
var payload = new byte[payloadLength];
|
||||
if (payloadLength != 0)
|
||||
{
|
||||
if (!await ReadExactAsync(stream, payload, token)) return null;
|
||||
var rawPayloadHead = Convert.ToHexString(payload.AsSpan(0, Math.Min(16, payload.Length)));
|
||||
cryptor?.XorInPlace(payload);
|
||||
Console.Error.WriteLine($"[FRAME-DBG] ok len={payloadLength} mode={head[4]} rawPayloadHead={rawPayloadHead} decHead={Convert.ToHexString(head)}");
|
||||
}
|
||||
|
||||
return new Frame(head[4], payload);
|
||||
}
|
||||
|
||||
// Public/plain overload retained for the plaintext handshake reply.
|
||||
public static Task WriteFrameAsync(NetworkStream stream, byte mode, ReadOnlyMemory<byte> payload, CancellationToken token)
|
||||
=> WriteFrameAsync(stream, mode, payload, null, token);
|
||||
|
||||
internal static async Task WriteFrameAsync(
|
||||
NetworkStream stream,
|
||||
byte mode,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
UxChaCha8? cryptor,
|
||||
CancellationToken token)
|
||||
{
|
||||
// Client Send() constructs [LE payloadLength][mode][payload] and, once
|
||||
// CryptorWrite exists, encrypts that full range in one continuous call.
|
||||
var frame = new byte[5 + payload.Length];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(frame.AsSpan(0, 4), payload.Length);
|
||||
frame[4] = mode;
|
||||
payload.Span.CopyTo(frame.AsSpan(5));
|
||||
|
||||
cryptor?.XorInPlace(frame);
|
||||
|
||||
await stream.WriteAsync(frame, token);
|
||||
await stream.FlushAsync(token);
|
||||
}
|
||||
|
||||
private static async Task<bool> ReadExactAsync(NetworkStream stream, byte[] buffer, CancellationToken token)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), token);
|
||||
if (read == 0) return false;
|
||||
offset += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Ananta.SDK.Logging;
|
||||
using Ananta.SDK.Rpc;
|
||||
|
||||
namespace Ananta.SDK.Network;
|
||||
|
||||
public sealed class TcpServer
|
||||
{
|
||||
private readonly string _scope;
|
||||
private readonly IPEndPoint _endpoint;
|
||||
private readonly RpcRouter _router;
|
||||
private readonly Func<TcpSession, Frame, CancellationToken, Task>? _customFrameHandler;
|
||||
private readonly ServerLogger _log;
|
||||
|
||||
public TcpServer(string scope, string host, int port, RpcRouter router, Func<TcpSession, Frame, CancellationToken, Task>? customFrameHandler = null)
|
||||
{
|
||||
_scope = scope;
|
||||
_endpoint = new IPEndPoint(IPAddress.Parse(host), port);
|
||||
_router = router;
|
||||
_customFrameHandler = customFrameHandler;
|
||||
_log = new ServerLogger(scope);
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken token)
|
||||
{
|
||||
var listener = new TcpListener(_endpoint);
|
||||
listener.Start();
|
||||
_log.Info($"listening at {_endpoint}");
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync(token);
|
||||
_ = Task.Run(() => HandleClientAsync(client, token), token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleClientAsync(TcpClient client, CancellationToken token)
|
||||
{
|
||||
var session = new TcpSession(client, _scope);
|
||||
session.Log.Info($"+ {client.Client.RemoteEndPoint}");
|
||||
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested && client.Connected)
|
||||
{
|
||||
var frame = await session.ReadFrameAsync(token);
|
||||
if (frame is null) break;
|
||||
|
||||
if (frame.Value.Mode == 0x02)
|
||||
{
|
||||
session.Log.Info($"HS {frame.Value.Payload.Length}b");
|
||||
await session.SendHandshakeAsync(frame.Value.Payload, token);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_customFrameHandler is not null)
|
||||
{
|
||||
await _customFrameHandler(session, frame.Value, token);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _router.DispatchAsync(session, frame.Value, token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
session.Log.Error("session error", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.Log.Info("-");
|
||||
session.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using Ananta.SDK.Logging;
|
||||
using Ananta.SDK.Rpc;
|
||||
|
||||
namespace Ananta.SDK.Network;
|
||||
|
||||
public sealed class TcpSession
|
||||
{
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
private UxChaCha8? _readCryptor;
|
||||
private UxChaCha8? _writeCryptor;
|
||||
|
||||
public string Id { get; } = Guid.NewGuid().ToString("N")[..6];
|
||||
public TcpClient Client { get; }
|
||||
public NetworkStream Stream { get; }
|
||||
public ServerLogger Log { get; }
|
||||
public Dictionary<string, object> Items { get; } = new();
|
||||
public bool CryptReady => _readCryptor is not null && _writeCryptor is not null;
|
||||
|
||||
public TcpSession(TcpClient client, string scope)
|
||||
{
|
||||
Client = client;
|
||||
Stream = client.GetStream();
|
||||
Log = new ServerLogger($"{scope}:{Id}");
|
||||
}
|
||||
|
||||
public Task<Frame?> ReadFrameAsync(CancellationToken token)
|
||||
=> FrameCodec.ReadFrameAsync(Stream, _readCryptor, token);
|
||||
|
||||
public async Task SendHandshakeAsync(ReadOnlyMemory<byte> clientHandshake, CancellationToken token)
|
||||
{
|
||||
if (clientHandshake.Length < 20)
|
||||
throw new InvalidDataException($"4229938 C2S handshake is only {clientHandshake.Length} bytes");
|
||||
|
||||
// C2S handshake layout: 4229938 SendHandShakeFrontEnd writes
|
||||
// magic(4) + the plain 16-byte UXAES.Generate() key at [4..20)
|
||||
// (its UXRSA.Encrypt call is patched out client-side), padded to 260.
|
||||
byte[] aesKey = clientHandshake.Slice(4, 16).ToArray();
|
||||
Log.Info($"HS raw[0..32]={Convert.ToHexString(clientHandshake.Slice(0, 32).ToArray())}");
|
||||
|
||||
// Decrypted S2C handshake blob expected by TrySetupChaChaOnHandshake:
|
||||
// [0..32) = client's write key => server READ key
|
||||
// [32..64) = client's read key => server WRITE key
|
||||
// [64..76) = shared 96-bit nonce
|
||||
// Empirically verified against 4229938: the client encrypts its frames
|
||||
// with blob[0..32) (FrameCodec raw-head capture + keystream match).
|
||||
byte[] clientWriteKey = RandomNumberGenerator.GetBytes(32);
|
||||
byte[] clientReadKey = RandomNumberGenerator.GetBytes(32);
|
||||
byte[] streamNonce = RandomNumberGenerator.GetBytes(12);
|
||||
byte[] chaChaHead = new byte[76];
|
||||
clientWriteKey.CopyTo(chaChaHead, 0);
|
||||
clientReadKey.CopyTo(chaChaHead, 32);
|
||||
streamNonce.CopyTo(chaChaHead, 64);
|
||||
|
||||
byte[] aesNonce = RandomNumberGenerator.GetBytes(12);
|
||||
byte[] cipher = new byte[76];
|
||||
byte[] tag = new byte[16];
|
||||
using (var aes = new AesGcm(aesKey, 16))
|
||||
aes.Encrypt(aesNonce, chaChaHead, cipher, tag);
|
||||
|
||||
byte[] payload = new byte[368];
|
||||
BitConverter.GetBytes(1).CopyTo(payload, 0);
|
||||
BitConverter.GetBytes(10).CopyTo(payload, 4);
|
||||
aesNonce.CopyTo(payload, 8);
|
||||
cipher.CopyTo(payload, 20);
|
||||
tag.CopyTo(payload, 96);
|
||||
// payload[112..368] is the RSA-2048 signature field. The client's
|
||||
// UXRSA.Verify result branch is patched out to accept the local
|
||||
// unsigned handshake.
|
||||
|
||||
await _sendLock.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
// The handshake reply itself is plaintext. Encryption becomes active
|
||||
// only for the next frame in each direction.
|
||||
await FrameCodec.WriteFrameAsync(Stream, 0x01, payload, cryptor: null, token: token);
|
||||
_readCryptor = new UxChaCha8(clientWriteKey, streamNonce);
|
||||
_writeCryptor = new UxChaCha8(clientReadKey, streamNonce);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
|
||||
Log.Info($"HS keys clientWrite={Convert.ToHexString(clientWriteKey)} clientRead={Convert.ToHexString(clientReadKey)} nonce={Convert.ToHexString(streamNonce)} aes={Convert.ToHexString(aesKey)}");
|
||||
|
||||
Log.Info("HS ok -> UXChaCha8 enabled");
|
||||
}
|
||||
|
||||
public Task SendFrameAsync(byte mode, byte[] body, CancellationToken token)
|
||||
=> WriteEncryptedFrameAsync(mode, body, token);
|
||||
|
||||
public Task NotifyAsync(uint methodId, byte[] body, CancellationToken token)
|
||||
{
|
||||
RuntimeLogs.OutgoingNotify(Log.Scope, methodId, body);
|
||||
return WriteEncryptedFrameAsync(0x09, RpcPacketWriter.WriteNotify(methodId, body), token);
|
||||
}
|
||||
|
||||
public Task ReturnAsync(uint methodId, int invokeId, int err, byte[] body, CancellationToken token)
|
||||
{
|
||||
Log.Info($"<- ret {RpcMethodNames.Display(methodId)} #{invokeId} e={err} {body.Length}b");
|
||||
RuntimeLogs.OutgoingReturn(Log.Scope, methodId, invokeId, err, body);
|
||||
return WriteEncryptedFrameAsync(0x09, RpcPacketWriter.WriteReturn(methodId, invokeId, err, body), token);
|
||||
}
|
||||
|
||||
private async Task WriteEncryptedFrameAsync(byte mode, ReadOnlyMemory<byte> body, CancellationToken token)
|
||||
{
|
||||
await _sendLock.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
await FrameCodec.WriteFrameAsync(Stream, mode, body, _writeCryptor, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
try { Client.Close(); } catch { }
|
||||
_sendLock.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Numerics;
|
||||
|
||||
namespace Ananta.SDK.Network;
|
||||
|
||||
/// <summary>
|
||||
/// Exact stream cipher used by the 4229938 UXNetwork client.
|
||||
/// It is ChaCha8-shaped, but the four sigma constants are client-specific.
|
||||
/// </summary>
|
||||
internal sealed class UxChaCha8
|
||||
{
|
||||
// GameAssembly.dll 4229938 / UXChaCha8Impl::.ctor
|
||||
// NOTE: these deliberately differ from standard "expand 32-byte k".
|
||||
private const uint Sigma0 = 0x60707865;
|
||||
private const uint Sigma1 = 0x3321646e;
|
||||
private const uint Sigma2 = 0x79622d32;
|
||||
private const uint Sigma3 = 0x6b206e74;
|
||||
|
||||
private readonly uint[] _state = new uint[16];
|
||||
private readonly byte[] _keystream = new byte[64];
|
||||
private int _keystreamPos = 64;
|
||||
|
||||
public UxChaCha8(ReadOnlySpan<byte> key, ReadOnlySpan<byte> nonce)
|
||||
{
|
||||
if (key.Length != 32)
|
||||
throw new ArgumentException("UXChaCha8 key must be 32 bytes.", nameof(key));
|
||||
if (nonce.Length != 12)
|
||||
throw new ArgumentException("UXChaCha8 nonce must be 12 bytes.", nameof(nonce));
|
||||
|
||||
_state[0] = Sigma0;
|
||||
_state[1] = Sigma1;
|
||||
_state[2] = Sigma2;
|
||||
_state[3] = Sigma3;
|
||||
|
||||
for (var i = 0; i < 8; i++)
|
||||
_state[4 + i] = BinaryPrimitives.ReadUInt32LittleEndian(key.Slice(i * 4, 4));
|
||||
|
||||
// Client constructor sets word 12 to zero and uses a 96-bit nonce.
|
||||
_state[12] = 0;
|
||||
_state[13] = BinaryPrimitives.ReadUInt32LittleEndian(nonce.Slice(0, 4));
|
||||
_state[14] = BinaryPrimitives.ReadUInt32LittleEndian(nonce.Slice(4, 4));
|
||||
_state[15] = BinaryPrimitives.ReadUInt32LittleEndian(nonce.Slice(8, 4));
|
||||
}
|
||||
|
||||
public void XorInPlace(Span<byte> data)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < data.Length)
|
||||
{
|
||||
if (_keystreamPos == 64)
|
||||
{
|
||||
GenerateBlock();
|
||||
_keystreamPos = 0;
|
||||
}
|
||||
|
||||
var take = Math.Min(64 - _keystreamPos, data.Length - offset);
|
||||
for (var i = 0; i < take; i++)
|
||||
data[offset + i] ^= _keystream[_keystreamPos + i];
|
||||
|
||||
offset += take;
|
||||
_keystreamPos += take;
|
||||
}
|
||||
}
|
||||
|
||||
private void GenerateBlock()
|
||||
{
|
||||
Span<uint> x = stackalloc uint[16];
|
||||
_state.AsSpan().CopyTo(x);
|
||||
|
||||
// UXChaCha8 is 8 rounds = four column+diagonal double-rounds.
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
QuarterRound(ref x[0], ref x[4], ref x[8], ref x[12]);
|
||||
QuarterRound(ref x[1], ref x[5], ref x[9], ref x[13]);
|
||||
QuarterRound(ref x[2], ref x[6], ref x[10], ref x[14]);
|
||||
QuarterRound(ref x[3], ref x[7], ref x[11], ref x[15]);
|
||||
|
||||
QuarterRound(ref x[0], ref x[5], ref x[10], ref x[15]);
|
||||
QuarterRound(ref x[1], ref x[6], ref x[11], ref x[12]);
|
||||
QuarterRound(ref x[2], ref x[7], ref x[8], ref x[13]);
|
||||
QuarterRound(ref x[3], ref x[4], ref x[9], ref x[14]);
|
||||
}
|
||||
|
||||
for (var i = 0; i < 16; i++)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(_keystream.AsSpan(i * 4, 4), unchecked(x[i] + _state[i]));
|
||||
|
||||
// Client increments only the 32-bit block counter (state[12]).
|
||||
_state[12] = unchecked(_state[12] + 1);
|
||||
}
|
||||
|
||||
private static void QuarterRound(ref uint a, ref uint b, ref uint c, ref uint d)
|
||||
{
|
||||
a = unchecked(a + b);
|
||||
d ^= a;
|
||||
d = BitOperations.RotateLeft(d, 16);
|
||||
|
||||
c = unchecked(c + d);
|
||||
b ^= c;
|
||||
b = BitOperations.RotateLeft(b, 12);
|
||||
|
||||
a = unchecked(a + b);
|
||||
d ^= a;
|
||||
d = BitOperations.RotateLeft(d, 8);
|
||||
|
||||
c = unchecked(c + d);
|
||||
b ^= c;
|
||||
b = BitOperations.RotateLeft(b, 7);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user