This October, Huntress ran a month-long CTF with challenges releasing every day. It was a team event, but participated solo. I ended up in overall 7th place.
I wrote up my solutions to a small selection of my favorites.
Base-p-⌗
Here we’re given a file called based.txt
that contains the following content
楈繳籁萰杁癣怯蘲詶歴蝕絪敪ꕘ橃鹲𠁢腂𔕃饋𓁯𒁊鹓湵蝱硦楬驪腉繓鵃舱𒅡繃絎罅陰罌繖𔕱蝔浃虄眵虂𒄰𓉋詘襰ꅥ破ꌴ顂𔑫硳蕈訶𒀹饡鵄腦蔷樸𠁺襐浸椱欱蹌ꍣ鱙癅腏葧𔕇鱋鱸𓁮聊聍ꄸꈴ陉𔕁框ꅔ𔕩𔕃驂虪祑𓅁聨朸聣摸眲葮𖠳鵺穭𒁭豍摮饱恕𓉮詔葉鰸葭楷洳面𔕃𔑒踳𔐸杅𐙥湳橹驳陪楴氹橬𓄱蝔晏稸ꄸ防癓ꉁ𖡩鵱聲ꍆ稸鬶魚𓉯艭𔕬輷茳筋𔑭湰𓄲怸艈恧襺陷项譶ꍑ衮汮蹆杗筌蹙怰晘缸睰脹蹃鹬ꕓ脶湏赑魶繡罢𒉁荶腳ꌳ蕔𔐶橊欹𖥇繋赡𐙂饎罒鵡𒉮腙ꍮ楑恤魌虢昹𒅶效楙衎𔕙ꉨ𓈸𔑭樯筶筚絮𓁗浈豱ꉕ魔魧蕕聘筣鹖樫ꍖ汸湖萰腪轪𓉱艱絍笹艨魚詇腁𒁮陴顮虂癁
Given the name of the challenge and from previous experience, I deduced that this was base65536 encoded data. Using an online tool I decoded the data to get the following.
H4sIAG0OA2cA/+2QvUt6URjHj0XmC5ribzBLCwKdorJoSiu9qRfCl4jeILSICh1MapCINHEJpaLJVIqwTRC8DQ5BBQ0pKtXUpTej4C4lBckvsCHP6U9oadDhfL7P85zzPTx81416LYclYgEAOLgOGwKgxgnrJKMK8j4kIaAwF3TjiwCwBejQQDAshK82cKx/2BnO3xzhmEmoMWn/qdU+ntTUIO8gmOw438bbCwRv3Y8vE2ens9y5sejat497l51sTRO18E8j2aSAAkixqhrKFl8E6fZfotmMlw7Z3NKFmvp92s8+HMg+zTwaycvVQlnSn7FYW2LFYY0+X18JpB9LCYliSm6LO9QXvfaIbJAqvNsL3lTP6vJ596GyKIaXBnNdRJahnqYLnlQ4d+LfbQ91vpH0Y4NSYwhk8tmv/5vFZFnHWrH8qWUkTfgfUPXKcFVi+5Vlx7V90OjLjZqtqMMH9FhMZfGUALnotancBQAA
This seemed like normal base64, I put it into CyberChef, which immediately identified it as a PNG image that had been gziped and encoded in b64.
This was the output image
This image appeared to be 13 squares of various colors next to each other in a line. This number was notable to me because the length of the flag (38 chars, flag{}
plus an md5 hash) is very close to 3 times 13. Every color can be represented by 6 hex digits, and 2 hex digits represent a byte, so I made the obvious connection that the hex codes for the pixels encoded the flag. I used an online color picker tool to copy the hex codes for the pixels, which gave me this
666c61677b35383663663863383439633937333065613762323131326666663339666636617d20
I ran this through “from hex” in CyberChef to get the flag, flag{586cf8c849c9730ea7b2112fff39ff6a}
Ran Somewhere⌗
An OSINT challenge, it starts by giving us a file ran_somewhere.eml
. Upon opening it we can an email between [email protected]
and [email protected]
which included several base64-encoded attachments. The first one decoded to
<div style="font-family: Arial, sans-serif; font-size: 14px;"></div>
<span style="line-height:1.5;scrollbar-width:thin;scrollbar-color:rgba(0, 0, 0, 0) rgba(0, 0, 0, 0);font-family:system-ui, sans-serif">
Help Me IT!! My USB was stolen! I was headed into town for some work and stopped by a client's coffee shop to get work done. Everything was fine; I was working and drinking coffee. I got up to use the restroom; when I returned, I saw that my computer had been tampered with! All my work was closed out, and my flash drive with my projects was gone! I can't lose this; there was very important work on it! I thought the security tools you put in place would stop something like this!!
</span>
<div style="line-height:1.5;scrollbar-width:thin;scrollbar-color:rgba(0, 0, 0, 0) rgba(0, 0, 0, 0);font-family:system-ui, sans-serif">
<span style="scrollbar-width:thin;scrollbar-color:rgba(0, 0, 0, 0) rgba(0, 0, 0, 0)">
When I was looking at the desktop, I noticed three new files that were not there before. I opened one to see if they were my files, but they are a jumbled mess. I can't make any sense of it. I think it is that "ran somewhere" that your team keeps warning us about. I still don't know what it is, but please reverse this and get my USB back. I can't believe this happened!
</span>
</div>
<span style="line-height:1.5;scrollbar-width:thin;scrollbar-color:rgba(0, 0, 0, 0) rgba(0, 0, 0, 0);font-family:system-ui, sans-serif">
I am attaching those files so you can fix them.
</span>
<div style="line-height: 1.5; scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, 0) rgba(0, 0, 0, 0); font-family: system-ui, sans-serif; background-color: rgb(255, 255, 255);"></div>
<div style="line-height: 1.5; scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, 0) rgba(0, 0, 0, 0); font-family: system-ui, sans-serif; background-color: rgb(255, 255, 255);">
<br style="scrollbar-width:thin;scrollbar-color:rgba(0, 0, 0, 0) rgba(0, 0, 0, 0)">
</div>
<div style="line-height: 1.5; scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, 0) rgba(0, 0, 0, 0); font-family: system-ui, sans-serif; background-color: rgb(255, 255, 255);">
-
<span style="line-height:normal;scrollbar-width:thin;scrollbar-color:rgba(0, 0, 0, 0) rgba(0, 0, 0, 0);font-size:12pt">
Mack Eroni
</span>
</div>
<div style="line-height: 1.5; scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, 0) rgba(0, 0, 0, 0); font-family: system-ui, sans-serif; background-color: rgb(255, 255, 255);">
<span style="line-height:normal;scrollbar-width:thin;scrollbar-color:rgba(0, 0, 0, 0) rgba(0, 0, 0, 0);font-size:12pt">
President
<br style="line-height:1.5;scrollbar-width:thin;scrollbar-color:rgba(0, 0, 0, 0) rgba(0, 0, 0, 0)">
<br style="line-height:1.5;scrollbar-width:thin;scrollbar-color:rgba(0, 0, 0, 0) rgba(0, 0, 0, 0)">
</span>
</div>
<div style="line-height: 1.5; scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, 0) rgba(0, 0, 0, 0); font-family: system-ui, sans-serif; background-color: rgb(255, 255, 255);">
<span style="line-height:normal;scrollbar-width:thin;scrollbar-color:rgba(0, 0, 0, 0) rgba(0, 0, 0, 0);font-size:13.5pt">
<a rel="noreferrer nofollow noopener" target="_blank" href="https://sites.google.com/view/id-10-t/home" title="Check out our new website!" style="line-height:1.5;text-decoration:underline;cursor:pointer;scrollbar-width:thin;scrollbar-color:rgba(0, 0, 0, 0) rgba(0, 0, 0, 0)">
Check out our new website!
</a>
</span>
</div>
<div style="line-height: 1.5; scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, 0) rgba(0, 0, 0, 0); font-family: system-ui, sans-serif; background-color: rgb(255, 255, 255);">
<img alt="company logo.png" class="proton-embedded" src="cid:[email protected]">
<br>
</div>
<div class="protonmail_signature_block" style="font-family: Arial, sans-serif; font-size: 14px;">
<div class="protonmail_signature_block-proton"></div>
</div>
This gives some context to the challenge and explains the pun in the title. The important information here is the URL to the company’s website in the email signature, https://sites.google.com/view/id-10-t/home. Visiting this site shows a very basic mock company landing page, and also Not Licensed to provide solutions to anyone or anywhere, especially Maryland
in the footer.
The next chunk was the company logo, which we can ignore, but the one after that was more interesting. It was base64-encoded hex-encoded text, which when decoded read as follows
Hey There! You should be more careful next time and not leave your computer unlocked and unattended! You never know what might happen. Well in this case, you lost your flash drive. Don't worry, I will keep it safe and sound. Actually you could say it is now 'fortified'. You can come retrieve it, but you got to find it. I left a couple of files that should help.
- Vigil Ante
There was one last file in the email, and it was much larger than the previous ones. The start of the base64 (/9j/4AA
) made it obviously an image, I decoded it to get this.
I put this through Google Lens, and saw a bunch of stuff that looked kinda similar but wasn’t quite right. I then narrowed down the search by adding “Maryland” (from the company website) as a search term and saw this.
The first result was a link to TripAdvisor which called it “Frederick Ward Park”. I put this into the challenge website and was told it was wrong, so I looked further. Googling the park name led me to this website, which had another picture that included the colored brick path, and also the name “Reckord Armory”. I tried flag{Rekord Armory}
as the answer, and it worked like a charm. As an aside, I got first blood on this challenge which felt nice.
Zimmer Down⌗
For this challenge we were presented with a file called NTUSER.DAT
. Upon inspecting the file, it is obviously a
Windows registry hive file. The challenge name is Zimmer Down
, which appears like a reference to Zimmerman Tools. One of the tools is called RegistryExplorer.exe
, upon opening it we can import NTUSER.DAT
. The challenge description includes “A user interacted with a suspicious file on one of our hosts”, so I searched for “RecentDocs” and saw this.
The first thing that caught my eye was the file called VJGSuERgCoVhl6mJg1x87faFOPIqacI3Eby4oP5MyBYKQy5paDF.b62
. .b62
is a file extension I’ve never heard of before, but it felt like it could be a reference to base-62 encoding. I copied VJGSuERgCoVhl6mJg1x87faFOPIqacI3Eby4oP5MyBYKQy5paDF
into CyberChef and got the flag as an output.
The flag is right here, flag{4b676ccc1070be66b1a15dB601c8d500}
That’s Life⌗
This challenge provides us with a link to a web service that gives a file download along with an option for uploading a file, and the message “Download the gameoflife program, solve the challenge, and upload the solving file to receive the flag.”
The file provided is a Go binary, and upon running it prompts the user to zoom out their terminal window
After doing so and pressing enter, we’re presented with a grid that seems to be displaying a multicolor implementation of Conway’s Game of Life
For those unfamiliar, Conway’s Game of Life is represented by an infinite grid, each cell being able to be either alive or dead. From each frame to the next, there are several rules used to determine how the game state will advance.
- A living cell with one or fewer living adjacent cells dies
- A living cell with four or more living adjacent cells dies
- A dead cell with precisely three living adjacent cells becomes alive
Some interesting facts about the Game of Life are that it’s Turing complete, and also that it’s an undecideable problem, meaning that there is no algorithmic way to determine if a certain pattern will ever arise from another pattern at some point in the future.
After messing around with it for a bit I couldn’t see any input the user could provide to the program, at which point I began disassembling it with Ghidra.
Ghidra tends to make a mess of Golang code, but luckily this one wasn’t garbled too much, and the decompiler was able to identify the main.main
function.
At the start of main.main
I can see references to main.generateWinCriteria()
, main.promptToZoomTerminalOut()
, main.clearScreen()
, main.deserializeFromFile()
, main.newGrid()
, and main.Grid.display()
. Lower down in the function I see a block of code for printing a win message, code for saving the game state to a file, and a loop that appears to check win conditions.
From observing what the program does, and making some educated guesses, I can presume that this code is effectively doing the following
func main(){
generateWinCriteria() // Load the win criteria into memory
promptToZoomTerminalOut() // Print "[*] Zoom your terminal way out and press ENTER to begin >"
clearScreen() // Clear the screen
for {
err := deserializeFromFile() // Attempt to read the game state from game_state.pb
if err != nil {
newGrid() // Generate a new game state from scratch
}
Grid.display() // Render the game
wincon := false
for i := range 12 {
criteria := winCriteria[i]
if (/*win criteria check fails*/){
wincon = false
}
}
if wincon {
// Print win message
return
}
Grid.next() // Advance the game to the next frame
Grid.serializeToFile() // Save the new game state back to a file
}
}
It seems that in order to “win”, the game needs to exist in a state that meets 12 specific conditions. Additionally, the game is encoding its state using protobufs, and saving it to a file. A good starting point would be to figure out the protobuf schema so that I can read the saved game states myself.
Looking around in Ghidra, I did a search for pb
in the Data Type Manager, and found something that looked helpful.
Looking at these closer, Ghidra tells me that a Grid
object contains two int32 fields for Width
and Height
, and a RepeatedField of CellRow
called Rows
. A CellRow
object just contains a RepeatedField of Cell
, and a Cell
consists of a boolean called Alive
and an int32 called Color
. This information was more than enough for me to reconstruct the entire protobuf schema myself.
syntax = "proto3";
package main;
message Grid {
uint32 width = 1;
uint32 height = 2;
repeated CellRow rows = 3;
}
message CellRow {
repeated Cell cells = 1;
}
message Cell {
uint32 alive = 1;
uint32 color = 2;
}
Using this schema I was able to successfully parse the saved game state into something readable myself, and also generate my own arbitrary state to load into the game. Notably, the color
field is in the form of an ANSI escape code, meaning 31 is red, 32 is green, 33 is yellow, and so on.
From here, the next step was to investigate what the specific win conditions are that it checks against. I looked inside the generateWinCriteria()
function and saw this.
This function appears to create a 12-long array of WinCriteria
structs (which lines up with the win check looping 12 times in main
), and then copies some data into it. Going to the location where it copies from, we can see a block of 384 bytes of data, most of which are \x00
. Checking the definition for WinCriteria
in Ghidra I can see it’s 32 bytes long and consists of an int64 (8 bytes) named x
, an int64 named y
, an int64 named color
, a boolean (1 byte) named matched
, and 7 bytes of padding. This lines up with the 384 byte data chunk, because 12 * 32 = 384.
Using a hex editor I copied the data in question out of the binary, and using a python program I parsed it according to that struct definition.
import struct
"""
type WinCriteria struct { // 0x20 bytes
x int64 //0x0
y int64 //0x8
color int64 //0x10
matched bool //0x18
// padding (0x7)
}
"""
ANSI_BASE = "\033[0;"
ANSI_END = "\033[0m"
with open("windata.dat", "rb") as f:
data = f.read()
conditions = [data[i:i+0x20] for i in range(0, len(data), 0x20)]
for c in conditions:
x = struct.unpack('<q',c[0:0x8])[0]
y = struct.unpack('<q',c[0x8:0x10])[0]
color = struct.unpack('<q',c[0x10:0x18])[0]
matched = True if c[0x18:0x19] == b'\x01' else False
parsed = {
'x': x,
'y': y,
'color': color,
'matched': matched
}
print(ANSI_BASE + str(color) + 'm ' + str(parsed) + ANSI_END)
I added the ANSI color codes to this for fun, to make it easier to visualize what the color
field meant. Running this script gave me this output.
I still have no idea what matched
means, but generally this data makes sense, and the logical conclusion from here is that we’re supposed to make a game save file that meets these specific criteria. As an aside, I think the challenge author got x
and y
backwards.
I generated a python file with the proto definitions, and then wrote the following script to create a protobuf file containing the winning game state
from poggers_pb2 import Grid, Cell, CellRow
wincons = [
{'x': 10, 'y': 15, 'color': 31, 'matched': False},
{'x': 20, 'y': 25, 'color': 32, 'matched': False},
{'x': 30, 'y': 35, 'color': 33, 'matched': False},
{'x': 40, 'y': 45, 'color': 34, 'matched': False},
{'x': 25, 'y': 50, 'color': 35, 'matched': False},
{'x': 5, 'y': 55, 'color': 36, 'matched': False},
{'x': 15, 'y': 60, 'color': 37, 'matched': False},
{'x': 35, 'y': 65, 'color': 31, 'matched': False},
{'x': 45, 'y': 70, 'color': 32, 'matched': False},
{'x': 0, 'y': 75, 'color': 33, 'matched': False},
{'x': 1, 'y': 80, 'color': 34, 'matched': False},
{'x': 2, 'y': 85, 'color': 35, 'matched': False}
]
def make_game_state():
grid = Grid()
grid.width = 400
grid.height = 50
for y in range(grid.height):
row = CellRow()
for x in range(grid.width):
cell = Cell()
cell.alive = 0
cell.color = 0
row.cells.append(cell)
grid.rows.append(row)
for w in wincons:
grid.rows[w['x']].cells[w['y']].alive = 1
grid.rows[w['x']].cells[w['y']].color = w['color']
return grid
with open("game_state.pb", "wb") as f:
f.write(make_game_state().SerializeToString())
After doing this I ran the game again, and it printed out
[+++] Congratulations! You've won!
[+++] Submit the winning game_state.pb file to the grading server!
I submitted this file to the website, and was greeted with the flag
flag{fe2765f32414c8c621bc3c77d04fb85e}