Proof of Concept Rust Source Engine Client

No longer being updated or worked on.

A full game authentication is performed with Steam's GC as well as the game server itself. Net messages have been successfully sent and received with a community game server.

 TRACE se_client::source::subchannel > Fragments successfully decompressed
 TRACE se_client::source::channel    > --- read_messages() begin ---
 TRACE se_client::source::channel    > MESSAGE [id=16, size=113]: <-- svc_Print
 TRACE se_client::source::channel    > MESSAGE [id=34, size=518]: <-- svc_CmdKeyValues
 TRACE se_client::source::channel    > MESSAGE [id=8, size=114]: <-- svc_ServerInfo
 TRACE se_client::source::channel    > MESSAGE [id=4, size=8]: <-- net_Tick
 TRACE se_client::source::channel    > MESSAGE [id=12, size=2630]: <-- svc_CreateStringTable
  TRACE se_client::source::channel    > MESSAGE [id=12, size=12502]: <-- svc_CreateStringTable
   TRACE se_client::source::channel    > MESSAGE [id=12, size=34]: <-- svc_CreateStringTable
    TRACE se_client::source::channel    > MESSAGE [id=12, size=43067]: <-- svc_CreateStringTable

Emulating a source client signon

Source engine's signon process has gotten significantly more complicated over the years as CS:GO has transitioned to matchmaking rather than direct IP connection. In addition, the invention of Steam Game Sockets means that now the traffic that's being communicated between the client and the server is being proxied over a relay network embedded in the Steam backbone.

For this purpose, I'm focusing on the most basic kind of gameserver connection, one that is done directly over UDP to a target port.

For this project, I decided to use Rust to implement the networking... because why not, it's fun and I'm learning the new language.

Connectionless Packets

Source engine has two kinds of communications with a game client, Connectionless and NetChan. Both of these happen over UDP. Connectionless packets are plain unencrypted UDP packets with a 4 byte 0xFFFFFFFF header specifying that they are connectionless. Anyone can send a connectionless packet to a game server's UDP port (typically 27015). Typically this is used for querying information about the server before forming an actual connection, such as the legacy server browser which queried information about the current state and map of the server and displays it in a UI before the user connects.

Forming a new connection: Challenge

When not using matchmaking, everything about a connection begins from a client when the connect concommand (or similar) is executed pointing the client to connect to a certain ip and port. This bubbles down to the following function:

(baseclientstate.cpp:1058): 
void CBaseClientState::ConnectInternal( const char *pchPublicAddress, char const *pchPrivateAddress, int numPlayers, const char* szJoinType )

Here is the first instance of the function SetSignonState being run, which sets the current state of the state machine of the handshake between the server and the client.

(baseclientstate.cpp:1089): 
SetSignonState( SIGNONSTATE_CHALLENGE, -1, NULL );

Here are all of the states the client and server can be in for a single client connection. At all points of the process, the client and server must agree at what signon state the handshake is in or the process fails.

enum SIGNONSTATE
{
	SIGNONSTATE_NONE		= 0,	// no state yet; about to connect
	SIGNONSTATE_CHALLENGE	= 1,	// client challenging server; all OOB packets
	SIGNONSTATE_CONNECTED	= 2,	// client is connected to server; netchans ready
	SIGNONSTATE_NEW			= 3,	// just got serverinfo and string tables
	SIGNONSTATE_PRESPAWN	= 4,	// received signon buffers
	SIGNONSTATE_SPAWN		= 5,	// ready to receive entity packets
	SIGNONSTATE_FULL		= 6,	// we are fully connected; first non-delta packet received
	SIGNONSTATE_CHANGELEVEL	= 7,	// server is changing level; please wait
};

OOB packets are synonymous with connectionless packets

This queues the client to begin sending packets to the server requesting a challenge. The actual request of the challenge happens here:

(baseclientstate.cpp:1381): 
void CBaseClientState::CheckForResend ( bool bForceResendNow /* = false */ )

This function is responsible for repeatedly poking the server and asking for a connection challenge. The packet used to request this challenge is A2S_GETCHALLENGE and the payload is of the pseudo-structure form:

{
	CONNECTIONLESS_HEADER: u32
	TYPE: u8 = A2S_GETCHALLENGE
	CONNECTION_STRING: String = "connect0xAABBCCDD"
}

Where the connection string is of the format connect0x%08X appending a 4-byte challenge to the message. This challenge is always equal to the last challenge value received from any server that the client tried to connect to. Otherwise, if the client just launched, this value is equal to 0x00000000.

Now, the server receives the OOB A2S_GETCHALLENGE from a client and processes the inner message to see that it is a connect message. It then builds a response, of OOB type S2C_CHALLENGE. This takes place in:

(baseserver.cpp:1631): 
void CBaseServer::ReplyChallenge( const ns_address &adr, bf_read &inmsg )

The server then randomly generates a challenge number to use for the connection and stores it into a large vector of all challenges for all clients that have ever tried to initiate a connection.

The expected result is that the server will respond with connect-retry and the cookie the server wants the client to send. Then, on the next attempt, the client will try again but with the requested value. The server will then accept it and respond with a context of connect instead.

It then writes back the response:

PROTOCOL_STEAM is always used over PROTOCOL_HASHEDCDKEY except if the server is a listen server on a client which has no steam connection

Gameservers now all have their own steam id, either linked with a steam account or using an anonymous steam id. It can be used to uniquely identify a server, regardless of IP.

Next is the response of the the challenge which determines if the client is allowed to connect. A few factors go into this decision

That's a big packet.

So at this point it should look like this:

and now both sides have verified the challegne.

Here is a dump of a successful challenge:

[src\main.rs:35] &packet = A2sGetChallenge {
    connect_string: "connect0x00000000",
}
[src\main.rs:40] &_res = S2cChallenge {
    challenge_num: 233306117,
    auth_protocol: PROTOCOL_STEAM,
    steam2_encryption_enabled: 0,
    gameserver_steamid: 90136361812869131,
    vac_secured: 0,
    context_response: "connect-retry",
    host_version: 13758,
    lobby_type: "public",
    password_required: 0,
    reservation_cookie: 18446744073709551615,
    friends_required: 0,
    valve_ds: 0,
    require_certificate: 0,
}
[src\main.rs:44] &packet = A2sGetChallenge {
    connect_string: "connect0x0de7f805",
}
[src\main.rs:49] &_res = S2cChallenge {
    challenge_num: 233306117,
    auth_protocol: PROTOCOL_STEAM,
    steam2_encryption_enabled: 0,
    gameserver_steamid: 90136361812869131,
    vac_secured: 0,
    context_response: "connect0x0de7f805",
    host_version: 13758,
    lobby_type: "public",
    password_required: 0,
    reservation_cookie: 18446744073709551615,
    friends_required: 0,
    valve_ds: 0,
    require_certificate: 0,
}

Connect packet + NetChannel creation

Once the challenge handshake is complete, the client calls into:

void CBaseClientState::SendConnectPacket ( const ns_address &netAdrRemote, int challengeNr, int authProtocol, uint64 unGSSteamID, bool bGSSecure )

to send the C2S_CONNECT packet to initiate a netchannel. The connect packet contains extra misc. information about the client. The important part of this packet is the User Info block, which is responsible for encoding all of the CVars on the client marked with FCVAR_USERINFO. All of these cvars are marked as such because the server wants to be able to query these without having to do a roundtrip with the client. An example of an FCVAR_USERINFO CVar would be name, which stores the name of the player they want to use.

This packet is the first instance of Protobuf packets being used in the connection. In the CS:GO version of the engine and beyond, most all packet communication is done using Protobuf packets. Prior to the introduction of Protobuf, everything was done manually by writing and reading values from buffers similarly to how the Connectionless packets still function. Now Protobuf handles that automatically.

This packet is especially curious because it is not a Protobuf packet in itself, but it contains an embedded Protobuf packet. Specifically, it contains the Protobuf packet called CCLCMsg_SplitPlayerConnect, which stores all of the User Info CVars talked about previously. Only cvars actually modified from their default value will be sent, otherwise it is assumed on the server to be default values. For each split player connecting, there will be a CCLCMsg_SplitPlayerConnect protobuf packet encoded into the packet. All CVars are sent as strings, even if their actual values are integers or floats. The server will interpret these string values as any kind of integer value when it receives the cvars.

The protobuf definition is given to us from Valve:

message CCLCMsg_SplitPlayerConnect
{
	optional CMsg_CVars convars = 1;
}

The actual CVars are iterated and added to the Protobuf packet in the function:

Host_BuildUserInfoUpdateMessage( playerCount, splitMsg.mutable_convars(), false );

Something special about this protobuf message is that the different cvars can be encoded into an index form instead of a full name. These cvars are hardcoded the list appears to include all of the userinfo cvars that are typically sent as part of a connection. Here is the list of all cvars that are encoded this way:

These can of course also be sent by name rather by index. This seems to mostly be done for performance reasons.

In addition, this is where the Steam authentication process begins.

The call to GetAuthSessionTicket is a steamapi function which Retrieve ticket to be sent to the entity who wishes to authenticate you.

The total auth buffer is a combination of:

[64] int64 steamid
[X ] auth session ticket
[64] size of ticket + steamid 

Then the auth buffer is written in the following form:

[16] Size of steam cookie
[X] Auth buffer

A curious part of this entire auth buffer is that it has two separate sizes, one for the cookie entirely and another for the size of the ticket itself.

Here's the format:

-> Reservation cookie 9a2a387bc911bda3:  reason [R] Connect from 192.168.1.100:27005

On Fragments

So most of my time implementing subchannels is learning how fragments actually work. Here's what I've learned so far:

Signon: CONNECTED -> NEW

So, now that we've authenticated and connected and formed a netchannel, the first thing the client must do is send a set signon message to set the signon to CONNECTED. This initiates the server to send the set of reliable netmessages to get the player ready to spawn in on their side.

Here are all of the messages sent in the reliable (subchannel) buffer:

Signon (client): CONNECTED (old) -> NEW (new)

When the above finishes, next the client calls SendClientInfo which constructs a CCLCMsg_ClientInfo to send to the server. Fields here include:

Then the client sends a net_SignonState for NEW containing the spawn_count from the last net_SignonState received. This must always be sent on new signon updates, else the server will force a reconnect.

Unfortunately, there is no good way to calculate the send tables CRC on the client.

Signon (server): NEW (old) -> PRESPAWN (new)

Server verifies the class tables CRC to match its own. If it's invalid (aka the client binary is out of date) it will disconnect with Server uses different class tables. This is what spawned me to write the crcgrab.exe project which signature scans for the class table crc value and then ReadProcessMemory's it from a real csgo.exe instance.

Server sends the signon data buffer to the client. This signon data is the same thing sent to all clients. It consists of every call to BroadcastMessage with IsInitMessage set made by the server during this map load. This catches up the client to everything that happened. I am not convinced there are any packets marked with FLAG_INIT_MESSAGE right now in the engine.

The server sends a signon state PRESPAWN to client.