TryHackMe - Grand Larceny Auto II CFT

 





Grand Larceny Auto 2 — CTF Write-up

Introduction

The challenge description gives several clues:

“The cheat console lies. The vault on your screen lies. The real score is something you have to earn, then prove you earned it.”

The important parts were:

  • the flag is not obtained through the cheat console;
  • the vault cannot simply be opened locally;
  • the game communicates with a backend server;
  • some kind of score/progress must be earned;
  • finally, that progress has to be presented to the server correctly.

The goal was therefore to reverse-engineer the game's client and reproduce its communication with the backend.


1. Inspecting the game files

The challenge provided a Godot game with a .pck file and the game's DLL.

Initially, inspecting the PCK with strings already revealed several interesting scripts:

res://scripts/GameController.cs
res://scripts/CheatConsole.cs
res://scripts/CryptoUtil.cs
res://scripts/PlayerState.cs
res://scripts/SafehouseVault.cs
res://scripts/WantedSystem.cs

The game was a Godot Mono application, so the interesting logic was contained in C# assemblies/scripts.

I used GDRE Tools to recover the Godot project from the PCK.

After recovery, the project contained:

scripts/
├── CheatConsole.cs
├── CryptoUtil.cs
├── GameController.cs
├── Models.cs
├── PoPClient.cs
├── PlayerState.cs
├── SafehouseVault.cs
└── WantedSystem.cs

The most interesting file turned out to be:

PoPClient.cs

2. Finding the backend

Inside PoPClient.cs, the server URL was immediately visible:

public string ServerUrl = "http://gla2.thm";

The client defined three important endpoints:

POST /session
POST /checkpoint
POST /claim

This confirmed that the game was not simply checking the vault locally.

It was communicating with a backend which maintained the actual challenge state.


3. Understanding /session

The game starts a session with:

POST /session
Content-Type: application/json

{}

I reproduced this with curl:

curl -i -X POST http://gla2.thm/session \
-H 'Content-Type: application/json' \
-d '{}'

The server returned:

{
"session_id": "XRp-IOuAbtwqh4T-0aK6G4JT",
"stash_order": [2,1,0],
"token": "PbqXb9BuIjMovJtLh68_nEDu"
}

Three values were important:

  • session_id
  • token
  • stash_order

The stash order is server-controlled, so it shouldn't simply be guessed.


4. Reverse-engineering the signature

PoPClient.cs contained the signing key:

private static readonly byte[] SignKey =
Encoding.UTF8.GetBytes("gla2_crew_sign_v1_2f9b6c8ad14e");

The signing function was:

private static string Sign(string msg)
{
byte[] array =
HMACSHA256.HashData(
SignKey,
Encoding.UTF8.GetBytes(msg));

...
}

Therefore the server expected an HMAC-SHA256 signature using the embedded key.

For checkpoints, the message being signed was:

session_id|step|token

This was a crucial discovery.


5. Following the checkpoint chain

The first checkpoint was:

heat5

For example:

XRp-IOuAbtwqh4T-0aK6G4JT|heat5|PbqXb9BuIjMovJtLh68_nEDu

I generated the HMAC locally and sent:

POST /checkpoint

The server responded:

{
"ok": true,
"step": "heat5",
"next": "stash2",
"token": "K8aaW-RcHNf8FH0v6IwZDK9y"
}

The important observation was that the token changed after every successful checkpoint.

So the next signature had to use the new token.

The complete sequence for this session was:

heat5
stash2
stash1
stash0
vault

Each step was accepted by the server and returned a fresh token.

This is essentially the server-side proof-of-progress mechanism hinted at in the challenge description.


6. Completing the vault sequence

After stash0, the server returned:

{
"ok": true,
"step": "stash0",
"next": "vault",
"token": "wiQScinsBlIl5pqneJVoEy4I"
}

The vault checkpoint was then signed using the new token.

The server accepted it:

{
"ok": true,
"step": "vault",
"next": null,
"token": "_XDsWl_F9KJmW9hhEHBFXNas"
}

At this point the entire checkpoint sequence had been completed.


7. The first flag — and why it was fake

The next endpoint was:

POST /claim

The client normally sends:

{
"session_id": "...",
"role": "player",
"token": "...",
"sig": "..."
}

The signature is generated over:

session_id|claim|token

Notice something important:

role is not included in the signed message.

The normal claim using:

"role": "player"

returned:

{
"flag": "THM{n1c3_dr1v1ng_but_th4ts_th3_wr0ng_v4ult}",
"tier": "player",
"note": "civilian access — the real vault is staff-only"
}

This was clearly not the final flag.

The server itself told us:

civilian access — the real vault is staff-only

This also explained the challenge hint about the vault on the screen lying.


8. Finding the staff role

PoPClient.cs contained another interesting method:

public string DeriveStaffRole()
{
string s =
"heat5_stash" +
StashOrder[0] +
"_stash" +
StashOrder[1] +
"_stash" +
StashOrder[2] +
"_vault";

byte[] array =
SHA1.HashData(
Encoding.UTF8.GetBytes(s));

...
}

Our server-provided stash order was:

[2,1,0]

Therefore the input to SHA-1 was:

heat5_stash2_stash1_stash0_vault

The resulting staff role was:

14eb9445237641434254f458e1aacacfbd529189

9. The authorization flaw

Now we had the interesting part.

The /claim signature was calculated from:

session_id|claim|token

It did not include the role field.

That means the following two requests have the same valid signature:

{
"session_id": "...",
"role": "player",
"token": "...",
"sig": "..."
}

and:

{
"session_id": "...",
"role": "14eb9445237641434254f458e1aacacfbd529189",
"token": "...",
"sig": "..."
}

The only changed field is role.

Because role wasn't covered by the HMAC, the server had no cryptographic proof that the role hadn't been modified.


10. Final claim

I reused the valid session, final token and existing signature, but changed only:

"role": "14eb9445237641434254f458e1aacacfbd529189"

The server accepted the request as a staff claim.

This demonstrated the vulnerability:

The authorization role is trusted by the server but is not authenticated by the request signature.

The complete chain was therefore:

Recover Godot project
Find PoPClient.cs
Discover backend
POST /session
Obtain session_id + token + stash_order
HMAC-SHA256 checkpoint chain
heat5 → stash2 → stash1 → stash0 → vault
POST /claim
Receive fake player flag
Derive staff role from stash order
Notice role is NOT part of HMAC
Replace role=player with derived staff role
POST /claim again
🏆 Final flag

Lessons learned

This challenge was a nice example of why authentication and authorization must be cryptographically bound together.

The developers correctly implemented HMAC authentication for the important session state:

session_id
step
token

but forgot to include the authorization-sensitive role parameter.

A safer design would have signed something like:

session_id|claim|token|role

or, preferably, have the server determine the user's authorization level entirely from server-side session state rather than trusting a client-supplied role.

The challenge also demonstrated another important CTF lesson:

Don't trust what the game UI tells you. Trust the protocol.

The cheat console produced a flag.

The normal vault claim produced a flag.

But neither represented the actual objective.

The real solution was obtained by reversing the client, understanding the backend protocol, completing the server-side proof-of-progress, and then identifying the authorization flaw in the final claim.


Final takeaway

The key discoveries were:

Backend:
http://gla2.thm

Endpoints:
POST /session
POST /checkpoint
POST /claim

Checkpoint signature:
HMAC-SHA256(
"gla2_crew_sign_v1_2f9b6c8ad14e",
session_id|step|token
)

Staff role derivation:
SHA1("heat5_stash2_stash1_stash0_vault")

Vulnerability:
role is not included in the /claim HMAC

And that was enough to turn the fake civilian claim into the real staff vault claim.

Video : https://youtu.be/Pjqeifpt4SM

Comments

Popular posts from this blog

TryHackMe - Typo Snare Threat Hunter Simulator (medium level)

TryHackMe - Matryoshka CFT

TryHackMe - Threat Hunting Simulator - Health Hazard