1337UP 2024 Game Hacking challenges

Bug Squash (part 1)

Bug Squash (part 2)

Lighthouse of DOOM

Bug Squash (part 1)

Squash those bugs!

We are given a Unity game where you get points for squashing bugs.

The game doesn't tell us what the win condition is, so we need to inspect its code. Luckily, Unity games are written in C# so you can use tools like ILSpy/dnSpy to inspect the game's code. Additionally, AssetRipper can be used to extract the assets (including scripts).

The compiled game code is found in the Assembly-CSharp.dll file.

// DNSPY: bug_squash1/Bug Hunting_Data/Managed/Assembly-CSharp.dll
private void CheckWinCondition()
{
    if (this.score == -1337)  {
        // win code      
    }
}

With dnSpy it's possible to edit the C# code with right click > Edit Method (Ctrl+Shift+E) then Save All (Ctrl+Shift+S). Modify the check to always win, squash one bug and gg!

Game hacking is indeed fun.

Bug Squash (part 2)

The developers learned some important things about cheaters and now hope they've learnt their lesson. Rumour has it, if you score more than 100,000 points in this game (within the 2 min time limit), you'll get a flag. Watch out for that new anti-cheat system though!

The second part was a lot more difficult as it had some protections to prevent cheating.

This time the game was compiled using IL2CPP, which converts the Intermediate Language (easy to decompile) to C++ code and generates a native binary (difficult to decompile). There is a tool called IL2CPPDumper, together with some Ghidra plugin it makes things easier but still quite annoying.

mkdir output
Il2CppDumper-win-v6.7.46\Il2CppDumper.exe `
    .\GameAssembly.dll `
    '.\Bug Hunting_Data\il2cpp_data\Metadata\global-metadata.dat' `
    output

I then used ripgrep to search the game directory for some words like "intigriti", and I quickly found a URL of what I assumed was the server.

rg -i intigriti
Bug Hunting_Data/app.info
1:Intigriti

output/stringliteral.json
25579:    "value": "https://bugsquash.ctf.intigriti.io",

Running Fiddler (with HTTPS decrypting) in the background showed the communication with the server.

POST /start_game
POST /update_score (
    {"user_id":"a72ac171-dcd2-4b4a-8422-b37476211784","bugs_squashed":1}
)

Maybe we can change the bugs_squashed to a higher value.

POST https://bugsquash.ctf.intigriti.io/update_score HTTP/1.1
Host: bugsquash.ctf.intigriti.io
Content-Type: application/json

{"user_id":"a72ac171-dcd2-4b4a-8422-b37476211784","bugs_squashed":100000}
{"error":"Invalid point value (100000.0). Anti-cheat is suspicious.","score":0}

At this point I'm modifying the JSON in every way I can think of. I came to the conclusion that bug_squashed only accepts the value of 1.

More fuzzing later I found no other places to put JSON or hidden parameters. I thought maybe I had to send 100k requests, but you could only send 3 requests per second so there was a maximum of 360 requests before the session expires. I remembered reading about the Single Packet Attack mentioning the fact that HTTP2 allows multiple requests in one packet, but this didn't work either.

Finally, I had an idea that led me somewhere. With some servers, you might have a proxy that does an initial check on the data before it sends it to the back-end. If both the proxy and the back-end have a different parser, it may lead to a parser differential. Imagine a JSON with duplicate keys where it checks the first one but uses the other.

It didn't work. However, duplicate keys where one is lowercase and the other is uppercase worked!

{"bugs_squashed":1, "BUGS_SQUASHED": 1} // +2 points!

Apparently, it doesn't care about the casing. There are 4096 possible variations of this we can put inside a single json which will get you to 100k points in 25 requests.

import requests
from time import sleep
import itertools

url = "https://bugsquash.ctf.intigriti.io"

r = requests.post(url + "/start_game")
user_id = r.json()["user_id"]

def generate_case_variations(s):
    return [''.join(x) for x in itertools.product(*((c.lower(), c.upper()) for c in s))]

data = {"user_id": user_id}

keys = generate_case_variations("bugs_squashed")

for k in keys:
    data[k] = "1"

for i in range(25):
    r = requests.post(url + "/update_score", json=data)
    print(r.text)
    sleep(0.2)

# {"message":"INTIGRITI{64m3_h4ck1n6_4n71ch347_15_4l50_fun!}","score":102400}

I think this challenge might have been too guessy because the trick didn't make much sense to me. Maybe if it had the parser differential I described earlier, the source code could be made available and the solution wouldn't be immediately apparent? Making challenges is hard.

Anyway, I still enjoyed it. gg!

Lighthouse of DOOM

When a game becomes too hard you start to look inside or go back to books.

We are given a file called lihouse.tap. I had never seen a .tap file before so this was new to me.

When you google tap file it gives results for Commodore 64. I ran it in some emulator and it didn't work. I ran strings on it to find the machine I needed and found a GitHub link: https://github.com/skx/lighthouse-of-doom. It mentions the use of a "ZX Spectrum emulator", so I found this other emulator called Fuse where I could load the tap file.

Seeing version for INTIGRITI I quickly realized I can look at the source code of the original to figure out its functionality.

// Word will have a term the user can enter, and a function-pointer
// of code to invoke.
//
// If a word is "hidden:1" it is not shown in the output of help.
word_t dictionary[] =
{
    { name: "DOWN", txt: "Descend the stairs", ptr: down_fn },
    { name: "DROP", txt: "Drop an item", ptr: drop_fn },
    { name: "EXAMINE", txt: "Examine an object or item", ptr: examine_fn },
    { name: "GET", txt: "Take an item", ptr: get_fn },
    { name: "HELP", txt: "Show some help", ptr: help_fn },
    { name: "INVENTORY", txt: "See what you're carrying", ptr: inventory_fn },
    { name: "LOOK", txt: "Look at your surroundings", ptr: look_fn },
    { name: "OPEN", txt: "Open an item", ptr: open_fn },
    { name: "QUIT", txt: "Quit the game", ptr: quit_fn },
    { name: "UP",   txt: "Climb the stairs", ptr: up_fn },
    { name: "USE", txt: "Use an item", ptr: use_fn },

    // Synonyms
    { name: "TAKE", hidden: 1, ptr: get_fn },
    { name: "PICKUP", hidden: 1, ptr: get_fn },
    { name: "READ", hidden: 1, ptr: examine_fn },
    { name: "EXIT", hidden: 1, ptr: quit_fn },
    { name: "BYE", hidden: 1, ptr: quit_fn },

    // Hidden-commands
    { name: "CLS", hidden: 1, ptr: cls_fn},
    [...]
};

It starts at the top floor. But I don't have the generator yet.

I can go DOWN and back UP. Some actions include EXAMINE, GET, OPEN, USE, etc. Knowing each command I could try to win the game.

The way I played the game was to use a bunch of those commands. The first screen suggests an action after me seeing "A small torch". I guess that the command should be get torch, and repeat this until I have a generator. Though beating the game isn't interesting for this challenge.

I need to see what is different in this version.

strings lihouse.tap > game.strings
strings original.tap > original.strings

code --diff original.strings game.strings
Sounds familiar! Go on.
Almost! One more.
Aww! Now look around...
[...]
Written by Steve Kemp in 2021, version for INTIGRITI.
  https://github.com/skx/lighthouse-of-doom
Any references to the Paw Patrol are entirely deliberate.
You have to CHEAT.
Press any key to start.$
[...]
$BOOKA small black book.$This seems to be a book of phone numbers.
The little black book seems to contain names and phone numbers:
       Police - 999
    Ambulance - 999
 Fire Service - 999
   Paw Patrol - 999
        Steve - ???
$A faded entry - ... 
[...]
SLEEP
WAIT
IDDQD
RFCPC
JCTCJ
UVFGPWRYJJ
READ
KILL
[...]

Besides small changes, an entry was added in a book with phone numbers and some random sequence of characters were added in between the commands.

If I send the first sequence of characters IDDQD, some text shows up

This matches with these three strings that were added:

Sounds familiar! Go on.
Almost! One more.
Aww! Now look around...

Apparently IDDQD is a cheat code for the DOOM game, but the other ones are not. They won't work either, no matter what I try. I was pretty confused at this point.

Failed idea #1: hidden phone number

As I became more familiar with the game by playing and looking at the source code, I noticed you could call the numbers on the book (middle floor only).

down > get book > read book

I thought there was a hidden phone number for the call command so I entered those cheat codes in there. None would work. I didn't see any other possibility by looking at the strings and thought there must be one hidden. Did anyone say brute force? Hold please!

(A)I wrote this autohotkey script to try them all. Run multiple emulators for faster result.

#NoEnv
SetKeyDelay, 100, 100
currentIndex := 0

F1:: ; Press F1 to type "call $i" and advance
    if (currentIndex > 999) {
        MsgBox, Loop has completed.
        return
    }
    
    i := Format("{:03}", currentIndex) 
    
    Send, c
    Send, a
    Send, l
    Send, l
    Send, {Space}
    
    Loop, Parse, i
    {
        char := A_LoopField
        Send, %char%
    }

    Send, {Enter}
    
    currentIndex++
    return

F2:: ; Reset loop
    currentIndex := 0
    MsgBox, Loop reset to 000.
    return

idk how long this took but whatever. It was a bold assumption that there's a hidden 3 digit phone number.

Failed idea #2: brute force program counter

This idea was even worse.

Basically I would save a snapshot via the emulator, change the starting address and see what happens.

# this will parse the z80 snapshot, change some stuff and open in fuse
# https://worldofspectrum.org/faq/reference/z80format.htm
import struct
import os
import pyautogui
import threading
from time import sleep

filename = "lihouse.z80"
data = open(filename, "rb").read()

for pc in range(1000):
    pc = 0x5000 + pc
    print("PC: %04x" % pc)

    data = data[:18] + struct.pack("<H", pc) + data[20:]
    open("snapshot_patch.z80", "wb").write(data)

    threading.Thread(target=os.system, args=("snapshot_patch.z80",)).start()

    sleep(0.3)
    # take a screenshot - or just record, idk
    # this is so silly
    # pyautogui.screenshot(f"images/screenshot_{'%04x' % pc}.png")
    os.system("taskkill /IM fuse.exe /F")

Failed idea #3: flag is just XORed

(another brute force)

Here my idea was that if the flag was encrypted with XOR, then I should be able to XOR it back using INTIGRITI and get the key. If the key is simple, it should be either one byte or an all ascii sequence which my script can detect.

def xor(a, b):
    return bytes(x ^ y for x, y in zip(a, b))

data = open("memory.bin", "rb").read()

for i in range(len(data)-len("INTIGRITI")):
    mem = data[i:i+len("INTIGRITI")]
    xored = xor(mem, b"INTIGRITI")

    if all(x == 0 for x in mem): continue

    if all(x == mem[0] for x in mem) and xored[0] != 0:
        if mem[0] == 0: continue
        print(f"{i} Possible key found {mem[0]:02x} - {xored.hex()} ({xored})")
        print(data[i:i+100].hex())
    if all(32 <= x < 127 for x in xored):
        print(f"{mem.hex()} - {xored.hex()} ({xored})")
    # else:
    #     print(f"{mem.hex()} - {xored.hex()}")

The next day

After all my attempts, I decided to look at the Z80 assembly code. I found this blog post about reverse engineering ZX Spectrum games in which he used a disassembler called skoolkit.

To generate the assembly:

python3 skoolkit/tap2sna.py lihouse.tap # get snapshot
python3 skoolkit/sna2skool.py lihouse.z80 > lihouse.skool # assembly

The blog post mentions generating an execution trace of the program, which gave me another idea.

Working solution

I made one trace in which I entered the cheat code, and one where I didn't. Comparing the two traces would give me an idea of where these checks are performed.

Diffing the two generated .map files narrowed it down to these addresses:

0x80dc (32988)
0x847e (33918) - check command?
0x87bb (34747) - draw cheat text (only when cheats)

Here is the assembly for the command checker (0x847e):

*33911 LD DE,45702    ; somewhere in commands
*33914 PUSH DE        ;
 33915 LD A,(DE)      ;
 33916 CP 0           ;
 33918 JR NZ,33924    ; where it branched
 33920 POP DE         ;
 33921 LD A,255       ;
 33923 RET            ;

So I wanted to know what was stored in 45702 (0xb286), when suddenly I spotted that one cheat code had changed!

THERE IS NO COW LEVEL is the next cheatcode. This will now reveal the next one: UUDDLRLRBA

I looked around the map, and sure enough the flag was inside the book.

gg!

Code Breaker

soon

Space Maze

soon