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-win-v6.7.46\Il2CppDumper.exe `
Il2CppDumper.\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.
/start_game
POST /update_score (
POST {"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
= "https://bugsquash.ctf.intigriti.io"
url
= requests.post(url + "/start_game")
r = r.json()["user_id"]
user_id
def generate_case_variations(s):
return [''.join(x) for x in itertools.product(*((c.lower(), c.upper()) for c in s))]
= {"user_id": user_id}
data
= generate_case_variations("bugs_squashed")
keys
for k in keys:
= "1"
data[k]
for i in range(25):
= requests.post(url + "/update_score", json=data)
r print(r.text)
0.2)
sleep(
# {"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
, 100, 100
SetKeyDelay:= 0
currentIndex
:: ; Press F1 to type "call $i" and advance
F1if (currentIndex > 999) {
, Loop has completed.
MsgBoxreturn
}
:= Format("{:03}", currentIndex)
i
, c
Send, a
Send, l
Send, l
Send, {Space}
Send
, Parse, i
Loop{
char := A_LoopField
, %char%
Send}
, {Enter}
Send
++
currentIndexreturn
:: ; Reset loop
F2:= 0
currentIndex , Loop reset to 000.
MsgBoxreturn
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
= "lihouse.z80"
filename = open(filename, "rb").read()
data
for pc in range(1000):
= 0x5000 + pc
pc print("PC: %04x" % pc)
= data[:18] + struct.pack("<H", pc) + data[20:]
data open("snapshot_patch.z80", "wb").write(data)
=os.system, args=("snapshot_patch.z80",)).start()
threading.Thread(target
0.3)
sleep(# take a screenshot - or just record, idk
# this is so silly
# pyautogui.screenshot(f"images/screenshot_{'%04x' % pc}.png")
"taskkill /IM fuse.exe /F") os.system(
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))
= open("memory.bin", "rb").read()
data
for i in range(len(data)-len("INTIGRITI")):
= data[i:i+len("INTIGRITI")]
mem = xor(mem, b"INTIGRITI")
xored
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