Hacking the highscore table of a towerdefense game



  1. Introduction

  2. Forging malicious POST requests ✅

  3. Database tweaking ✅

  4. Attempt to modify another player score ❌

  5. Additional informations

    1. A way to protect your data integrity easily

Introduction

A friend of mine gave me the website of a game created by a couple about to get married. They created that game to give various informations about their wedding and also to give a reward to the best player in the high score table.

The game is built using Unity game engine and could be ran from any web browser, it is a towerdefense kind of game in the universe of the Lord of the Ring. They did quite an amazing job. I apologies but I do not intend to share the link, I don’t want a bunch of people trying to break their game into pieces or even worst…

The interesting part is that the high score table is available for all players to see at anytime. Because of that, I tried to figure a way to get to the first place by cheating !

highscore_table The high score table

Gameplay

The game is composed of 4 levels. On each level, enemies are spawning from the edges and we are supposed to prevent them from leaving the map by placing traps and towers along their path. We start the level with a defined number of lives and each time an enemy leaves the level that counter is decreased by one. If we happen to reach 0, then the game will show a “game over” message.

Gathering technical informations

The first time you access the web game, you will need to chose a nickname that your device will remember, there is no password based authentication (which is a plus), you can start playing right after chosing a name and the browser will remember it next time you want to play.

To have an overview of what kind of network requests are done between the client (my computer) and the Unity servers, I used the network debuging tools that is included in Firefox.

But as soon as we start the game, nothing really excinting is happening on the network side, there are two HTTP GET requests that are worth mentionning:

I picked the first level and start playing.

During the round, there is not a single http request being sent or received, that is a good clue that the game is running locally.

If I loose the round, nothing is happening on the network debugger.

However, if I win, the game will compute my new best score and send it over as a POST request.

This best_score value is what will be displayed in the high score table.

Note: The game will send the POST request containing the best score ONLY if it is higher than the previous score we had before winning the level

I think we have enough information to start cheating.

Forging malicious POST request

Using CURL to forge the spoofing HTTP request

Ok, so let’s try to give myself a best score of 76 instead of the puny 31 I have.

I just took most of the content (URL, headers, data) from the debbuger and put them into this CURL command:

curl -d '{"data":[{"key":"best_score","value":76}]}' -k -H @head1213 -X POST "https://cloud-save.services.api.unity.com/v1/data/projects/<#REDACTED#>/players/vRu6NK8bZCN3xQ2bPJ1N9Navzg1d/item-batch"
-d : data you want to send
-k : Ignoring the validity of the server certificate
-H : Specify the header content to be sent along with the request
-X : Method, we will use POST here
The URL is built that way:
https://cloud-save.services.api.unity.com/v1/data/projects/<UnityProjectID>/players/<UserID>/item-batch

Note: There is a way to replay POST or GET requests and even changing their content directly from the Firefox network debbuger. That could get handy if you do not want to mess with CURL.

My UserID is: vRu6NK8bZCN3xQ2bPJ1N9Navzg1d

I was not sure of which headers were mandatory for the authentication, so I just copied all of the them from the original request and paste them into the file head1213 that curl read to get all the headers I needed to be sent with the POST request.

The forged CURL request appeared to be successful at first and we got this reply from the server:

I do not know what the writeLock is for, but the important thing is that we did not get any error.

Directly after seeing that, I went back to the high score table web page, I was eager to see my nickname at the top but… It was not there. The highest score was 60 back then, obviously not mine.

I decided to reload the whole web page and verify what the server thinks my best score is by reading the GET packet sent each time the game wants to verify my highest score.

It was definitly 76, so my POST request did work. Something is definitly blocking me from being at the top of that high score table. Maybe the game figured that I cheated somehow ?

I took my smartphone and created a new account from there and after some time playing without cheating I got a best score of 58. Not enough to be at the top but definitly enough to be on the TOP 20.

But… Still nothing, not even my new account is displaying on that damned high score web page.

I ran out of patience and reached out to the developper of the game to understand what the hell was happening. She just told me that she was not able to pull the players account list from Unity server and that she is updating a list of created accounts on her website everyday. I admited that I cheated and that she did not need to add my account to the list of all the players.

But let’s not just stop here, how about we try to modify our score from the local save file ?

Database hacking

At the beginning of this article, I mentionned that everytime the game is launched, it is getting the value of our best score from the Unity server. However the points for each levels are not pulled from the server, that means that they are stored locally on my computer somewhere. And if they are stored locally, there must be a way to modify them as we want.

Turns out that datebase (sqlite) storing the levels points along with some other information is located here:

C:\Users\Fabien\AppData\Roaming\Mozilla\Firefox\Profiles\<#REDACTED#>\storage\default\https+++<#REDACTED#>.com\idb

I am barely a beginner when it comes to database engine like SQL. So to help me find the levels points more easily I used a GUI sqlite DB explorer. Not a lot to see, a few tables with one or two records. One of the record contain a lot of data with mixed ASCII and binary characters.

Scrolling in that bunch of data is revealing the UserId and best_score fields in plain text, along with their values in binary for “best_score” and in plain text for “UserID”. But all of that is not relevant for now.

No ascii strings to help me to find the level names or anything. I first tried to highlight bytes that contain the amount of points I got on the four levels back then (0x06, 0x06, 0x05 and 0x08). I tried to find lines where those bytes would be close. But that was just showing too many result.

I then tried to update my points on a particular level to update the DB content so that I would be able to compare the old DB with the new one and find out which bytes changed.

I replayed the last level were I had 8 points initially and I managed to get to 14 points. I quit the game and put both database side to side on notepad++ using the compare plugin.

In this bottom DB chunk, two differences are visible:

No more thinking needed, this 0x0E byte was obviously part of the 4 bytes that represent the value of the last level score.

I then started to mess with it and override the 0x0E value to 0xFF (255) using hexedit.

Not very readable, but I am just changing the byte behind the green cursor

And after restarting the game…

Something is telling me the devs were not planning to have a player scoring a three digits number :).

So changing the amount of points per level is fun, but remember that the game will not recompute our best score (and send it to the server) unless we win at playing any level.

I just replayed one level at random (I picked Fondcombe), I managed to win (with 6 points) the level and while I was looking at the POST request being sent after winning:

Victory ! Best_score value is 273 as expected (255 + 6 + 6 + 6).

All of this was very fun to mess with, but unfortunalty my account would never show of the high score table because I was caught while cheating. If I cannot get to the first place, why not lowering the score of the best player :) ?

Attempting to modify the score of another player.

We already know the POST request to change our score, so I tried to use the very same one but by specifying another userID.

I will be using the following userID: j6KpG4YgajogBa9jnsod9M9mGO4Y

That was the ID from the best player back then.

curl -d '{"data":[{"key":"best_score","value":23}]}' -k -H @header20221311 -X POST "https://cloud-save.services.api.unity.com/v1/data/projects/<#REDACTED#>/players/j6KpG4YgajogBa9jnsod9M9mGO4Y/item-batch"

{"type":"problems/basic","title":"Error","status":403,"detail":"you are not permitted to access this user's data","instance":null,"code":53}

you are not permitted to access this user’s data

We are getting denied because of the “authorization” token within the header I specified to be used with the POST request.

POST /v1/data/projects/<#REDACTED#>/players/j6KpG4YgajogBa9jnsod9M9mGO4Y/item-batch HTTP/3
Host: cloud-save.services.api.unity.com
Accept: application/json
<#TRUNCATED#>
authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InB1Ymx<#TRUNCATED#>D7qW8F2qc9M9QpqgRp9JBK1AcuQ0ngUdqg
<#TRUNCATED#>
Sec-Fetch-Site: cross-site
TE: trailers

The very long string used as authorization token is acutally valid only for my userID and cannot be used for requesting/posting data about another userID.

But this is not just some random huge character list, it is actually a JSON Web Token encoded in Base64.

We can see below the content of the three JWT blocks (header, payload, signature) thanks to this site. And now it is becoming crystal clear why this authorization token could only work with my own UserID. For more information here is a webpage that brings a lot of details about JWT.

The authorization bearer long string content is on the left pane. The decoded JWT is on the right pane.

So you could think tweaking this JWT would be easy, just replacing my UserID (in the “sub” field) by the one of any other player shoud do the trick, right ?

Let’s try that, I changed the payload and replaced the content by:

"sub": "j6KpG4YgajogBa9jnsod9M9mGO4Y"

Then I encoded it back into Base64 and added the resulting string to the “authorization: Bearer” field of my new header file. Then…

curl -d '{"data":[{"key":"best_score","value":23}]}' -k -H @header20221311_tibo -X POST "https://cloud-save.services.api.unity.com/v1/data/projects/<#REDACTED#>/players/j6KpG4YgajogBa9jnsod9M9mGO4Y/item-batch"

{"status":401,"title":"Unauthorized","type":"problems/basic","requestId":"03f21186-66d7-43bc-aa7e-7d8e908e3af0","detail":"Authentication token could not be verified; invalid signature","code":51}

Authentication token could not be verified; invalid signature

If you were wondering what was purpose of the third block (signature) of a JWT file, you just found about it.

The signature part is verifying the integrity of the header part AND the payload part of the JWT. If you decide to modify anyhing from the header or the payload, you need to regenerate the signature/certificate that is accounting for the integrity of these parts of the JWT.

There are multiples way of signing your JWT, but the one that Unity API is using is RS256 (RSASHA256). So everytime you want to sign an JWT you need to do the following

RSASHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload), ServerPrivateKey)

Because I do not have the private key of the Unity API server, there is now way I can sign this JWT and have the Unity server to be able to verify the integrity of my modified JWT. It will just claim that the signature is invalid.

So messing with the JWT appears to be really complex, let’s find another way to trick Unity into thinking that I am another player.

The question we can ask ourselves is :

How do we get this JWT in the first place ?

Well, we get it very early each time we launch the game, the client is sending a POST request to https://player-auth.services.api.unity.com/v1/authentication/session-token, with a SessionToken (we will see what it is in a minute) as parameter.

The reply of the Unity API contains:

So what is this sessionToken ? Is there anyway we can mess with it so we can trick Unity server into replying with a properly signed JWT that would below to another user ?

Let’s have a look into the Unity documentation:

Session token: A token that’s used to re-authenticate the user after the session expires. It is a randomly generated string. In the authentication responses, the field is called sessionToken.

The fact that it is randomny generated is destroying the last hope I had to tinker with it. I guess that SessionToken must have been associated with my UserID the very first day I created my account and all that time it has been stored locally in my computer.

I gave up on modifying another player score as I have no way of getting the sessionToken of anybody else.

Additional informations

A way to protect your data integrity easily

If you are building the same kind of game, I would like to suggest a solution that could be added really easily to your existing codebase, to protect your data from being modified outside the game.

What I am suggesting is some kind of an integrity check, a bit like the one we saw with JWT but much more easier to implement (but not as efficient).

Let’s implement a checksum to validate the best_score value being sent to the server. We would call the additional key ‘validation’ and its value would be a sha256sum of the best_score value.

{
        "data":[
                {"key":"best_score","value":76},
                {"key":"validation","value":"f74efabef12ea619e30b79bddef89cffa9dda494761681ca862cff2871a85980"}
        ]
}

So before sending the request to the server, the client would compute this:

validation_client = sha256sum(best_score)

As soon as the server is getting this request, it would just have to recompute the sha256sum of the best_score value and ensure that the result is the same as the one caculated by the client (key ‘validation’).

That way, if somebody decide to change the best_score value of a request he captured before replaying it, the server would know something is wrong. That woud look like this:

Modified POST request:

{
        "data":[
                {"key":"best_score","value":299},
                {"key":"validation","value":"f74efabef12ea619e30b79bddef89cffa9dda494761681ca862cff2871a85980"}
        ]
}

The server is recomputing the best_score sha256sum after receiving the modified POST request.

validation_server = sha256sum(299)
validation_server = 308831041ea4863c3f87d222c31f759411898c874a9006b4bd6c745858b8f3bd

The two sha256sum hashes are different, the server can safely discard that request.

That’s sweet, but it would not take long for even a beginner hacker to find out how to build this integrity sum and change it as well as the best_score before replaying the request.

He could take the legitimate request and try varius checksum algorithmes on the best_score value and find out which one is giving the same result as the validation value. He can then choose any best_score value he wants and use that checksum algorithm to be able to compute a valid validation value. Then he would be able to replay that modifed request and the server would not suspect a thing.

Let’s add some salt to make that modification way harder.

The salt

A salt is a pre-defined string of char that we can append to the value that we want to hash to make it way less predictable.

As an example, let’s take the following salt : 0xfabear2022. We can appen the best_score value to that salt and then recompute the sha256sum:

validation_client = sha256sum("0xfabear2022"+"best_score)
validation_client = sha256sum("0xfabear202276")

validation_client : bb408c50fc9c81cf64dfc1d94b39fae44427c69391c7a0fd838fea776b162596

The salt must obvisouly be the same on both the client and the server. That way the server would be able to do the very same operation to verify the best_score integrity.

As our potential hacker would not be able to guess the salt easiliy, it could be really hard for him to compute a valid checksum for the best_score value he wants to modify.

You can make the salt way more longer and complex that the string I used in the example.