Skip to content

Introduction

When it comes to making bots or a private server for a given game, the first goal we have to keep in mind is to reverse-engineer and understand the protocol completely (or as much as possible). This is the major goal any game modder or hacker needs to achieve to be able to modify the game at will.

Inspecting the packets

For this purpose, you will first need a way of getting the decrypted packets to be prepared to start reversing them.

Since the game is multi-platform you can choose any platform you are most comfortable with, but keep in mind that the client version has an AntiCheat, which makes it harder to retrieve the packet bytes (because we cannot hook unless we figure out an AntiCheat bypass).

I will not go into much detail here but I have chosen to inspect the packets through a web userscript with Violentmonkey.

Here are some other approaches you could try:

  • Find a PC client AntiCheat bypass and use hooks on helper methods to retrieve the decrypted packets.
  • Use frida on an Android/iOS device or emulator.

There is no easy manner of doing this but some way may end up being easier for you.

Joining a lobby

The type of packets in charge of joining a lobby are PACKET_ENTER_WORLD.
Below is an outgoing parsed sample of these packets:

// Outgoing PACKET_ENTER_WORLD:
{
"displayName": "Player",
"version": 18,
"proofOfWork": [
210, 121, 12, 65, 100, 84, 178, 85, 56, 241, 104, 185, 195, 241, 55, 43,
31, 183, 215, 223, 176, 4, 54, 220
]
}
NAMEDESCRIPTION
displayNamedisplay name
versionthe protocol version
proofOfWork*PoW

*PoW (Proof of Work)

  • It is calculated differently for each platform and the algorithm changes across Codec versions.
  • It is what distinguishes the platform of the client to the server and therefore decides whether you need to send AntiCheat health packets or not.

RPCs and the EnterWorldResponse

RPC stands for Remote Procedure Call but basically and for our purposes, RPCs are just a type of packets called PACKET_RPC with id 9 that handle the major part of intercommunication between clients and server on the game, being the ones encrypted and most important next to PACKET_ENTER_WORLD. Here is a decrypted sample of an RPC:

// Outgoing PACKET_RPC:
Uint8Array(15) [ 9, 42, 0, 0, 0, 3, 87, 101, 98, 0, 0, 0, 0, 0, 0 ]

This type of RPC is actually called “SetPlatformRpc” and this sample is from the web client (you’ll see why that’s important in a bit). There are also incoming RPCs, and both incoming and outgoing are encrypted, so we have to previously decrypt them from our homebrew toolset to actually inspect them and start reversing.
This is the first RPC decryption layer.

RPC types

There are also PACKET_RPC sub-types (such as “SetPlatformRpc”) identified by the second value on the packets (42, 0, 0, 0 on our sample) of type Uint32, which is the reason why it has a padding of zeros.

These sub-types are actually called indexes and they correspond to RPC internal C# classes that inherit from either OutRpc or InRpc types of the game, depending on whether it is an outgoing or incoming RPC.

The game protocol is obfuscated in a way that both the client and server have so called internalIds that are matched to random indexes when a client joins a lobby.
I know this may seem confusing at first but here is where the EnterWorldResponse comes into play.

The EnterWorldResponse and internalId’s

If you have been following along, you will remember the outgoing PACKET_ENTER_WORLD. The EnterWorldResponse is no more than the server-to-client packet in response to our outgoing PACKET_ENTER_WORLD (client-to-server) sample. Here is our parsed EnterWorldResponse sample:

// Incoming PACKET_ENTER_WORLD:
{
"version": 18,
"allowed": 1,
"uid": 256,
"startingTick": 5186,
"tickRate": 64,
"effectiveTickRate": 64,
"players": 30,
"maxPlayers": 200,
"chatChannel": 0,
"effectiveDisplayName": "Player",
"x1": 0,
"y1": 0,
"x2": 20000,
"y2": 20000,
"entities": [
// ...
],
"rpcs": [
{
"index": 0,
"internalId": 2709538405,
"isArray": false,
"parameters": [
{ "id": 1082326051, "type": 0, "internalId": -1 },
{ "id": 3094559808, "type": 0, "internalId": -1 },
{ "id": 3633746829, "type": 8, "internalId": -1 },
{ "id": 639169537, "type": 8, "internalId": -1 },
{ "id": 201103873, "type": 0, "internalId": -1 },
{ "id": 509480188, "type": 8, "internalId": -1 },
{ "id": 3271804154, "type": 8, "internalId": -1 },
{ "id": 1122552831, "type": 8, "internalId": -1 },
{ "id": 2811707975, "type": 0, "internalId": -1 },
{ "id": 478880929, "type": 8, "internalId": -1 },
{ "id": 1968842697, "type": 3, "internalId": -1 },
{ "id": 2041678923, "type": 8, "internalId": -1 },
{ "id": 3526381527, "type": 0, "internalId": -1 }
]
},
// ...
],
"mode": "Solo",
"map": "Map1",
"udpCookie": 11264265,
"udpPort": 9002
}

You can take a look at the whole dump here.

Both the client and server have a copy of these internalIds. The server is choosing to use this set of them because it’s identifying the platform of the client as “Web” with the Proof of Work. This is possible because the PoW is calculated differently for each platform and each platform has their own set of internalIds for each Codec version (18 in this case) that both client and server must share to intercommunicate. The PoWs are always different per connection because they rely on the ingame server’s endpoint (randomized server-side each time).

Here is our “SetPlatformRpc” sample again:

// Outgoing PACKET_RPC:
Uint8Array(15) [ 9, 42, 0, 0, 0, 3, 87, 101, 98, 0, 0, 0, 0, 0, 0 ]

To reverse it, the first thing we have to do is look for the RPC type index (42 in this case, ignoring the padding) on the rpcs object of our EnterWorldResponse.
This is the part of the EnterWorldResponse’s rcps object we are interested in:

"rpcs": [
// ...
{
"index": 42,
"internalId": 942553282,
"isArray": false,
"parameters": [
{ "id": 1581339859, "type": 3, "internalId": -1 },
{ "id": 3417191498, "type": 8, "internalId": -1 },
{ "id": 107290053, "type": 8, "internalId": -1 },
{ "id": 3227619306, "type": 8, "internalId": -1 },
{ "id": 3775041204, "type": 8, "internalId": -1 },
{ "id": 235998359, "type": 8, "internalId": -1 },
{ "id": 3076491064, "type": 8, "internalId": -1 }
]
},
// ...
],

In this object of rpcs:

  • index is the randomized RPC sub-type id.
  • internalId is a hash-like internal identifier of the C# inheriting class of InRpc or OutRpc game types (in this case of “SetPlatformRpc”).
  • isArray determines if the RPC can be sent multiple times on the same packet as an array.
  • parameters contains the parameter structure of the RPC.

In our “SetPlatformRpc” sample, the first parameter is of type String which in this case represents the string “Web” in 4 bytes 3, 87, 101, 98, where the first byte is the string length and the rest are the “Web” characters in ASCII. The rest of the bytes of our outgoing RPC sample of type Uint8 are there just to confuse the reverse-engineer by randomizing the RPC structures on each connection.

If we ignore the dummy type 8 (Uint8) randomized parameters on this packet we are left with just the string “Web”. So we now know the “SetPlatformRpc” sends a platform string that can be “Web”, “Windows”, “Android” or “iOS”.