TryHackMe - Grand Larceny Auto II CFT
- Get link
- X
- Other Apps
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.csres://scripts/CheatConsole.csres://scripts/CryptoUtil.csres://scripts/PlayerState.csres://scripts/SafehouseVault.csres://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 /sessionPOST /checkpointPOST /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 /sessionContent-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_idsteptoken
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.thmEndpoints:POST /sessionPOST /checkpointPOST /claimCheckpoint 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
- Get link
- X
- Other Apps
Comments
Post a Comment