<h1 style="text-align:center">
HMIF COMQUALS 2025 - WriteUp
</h1>
<center>
Written by CaitViLover a.k.a LiroSphere

</center>
<h2 style="text-align:center">
Table Of Contents
</h2>
* [Digital Forensics](#Digital-Forensics)
* [Asep's Secret - 100](#Aseps-Secret---100)
* [Asep Kasep - 493](#Asep-Kasep---493)
* [Reverse Engineering](#Reverse-Engineering)
* [Yamada Ransom - 100](#Yamada-Ransom---100)
* [TBFO STIMA - 500](#TBFO-STIMA---500)
* [Cookie Monster - 500](#Cookie-Monster---500)
* [Cryptography](#Cryptography)
* [Easy Hash - 200](#Easy-Hash---200)
* [Misc](#Misc)
* [Irony - 100](#Irony---100)
* [Pulkam - 283](#Pulkam---285)
* [Afterword](#Afterword)
<br>
<h2 style="text-align:center">
Digital Forensics
</h2>
<h3 style="text-align:center">
Asep's Secret - 100
</h3>
<center>

</center>
We're given an attachment `chall.pcap`, inside we can find a lot of packets using the USB protocol. Looking closer at the info column of wireshark, there's a few thing. It starts by the usb device "establishing connection" to the computer, and then a lot of USB request block (URB) interrupts.
<center>

</center>
Remembering our OS project to make a whole operating system from scratch (thx labsister), when keyboard (or any input for that matter) needs to call an interrupt to actually input to the computer, hence the name USB request block (URB).
Looking closer into each URB_INTERRUPT packets, we found the packets that being sent from the device has HID (Human Interface Device) data, which contains 8 bytes of.... something. At this point i was a bit stumped, so we turn to out favorite LLM, and find out those are keyboard HID data! What's left to do is to decrypt them into actual text, and maybe we can find the flag? (clueless)
::: info
The keyboard mappings could be found on [Official USB Documentation](https://usb.org/sites/default/files/hut1_6.pdf)
:::
```py
mappings = {
0x04:"A", 0x05:"B", 0x06:"C", 0x07:"D", 0x08:"E", 0x09:"F", 0x0A:"G", 0x0B:"H",
0x0C:"I", 0x0D:"J", 0x0E:"K", 0x0F:"L", 0x10:"M", 0x11:"N", 0x12:"O", 0x13:"P",
0x14:"Q", 0x15:"R", 0x16:"S", 0x17:"T", 0x18:"U", 0x19:"V", 0x1A:"W", 0x1B:"X",
0x1C:"Y", 0x1D:"Z", 0x1E:"1", 0x1F:"2", 0x20:"3", 0x21:"4", 0x22:"5", 0x23:"6",
0x24:"7", 0x25:"8", 0x26:"9", 0x27:"0", 0x28:"n", 0x2a:"[DEL]", 0X2B:"[TAB]",
0x2C:" ", 0x2D:"-", 0x2E:"=", 0x2F:"[", 0x30:"]", 0x31:"\\", 0x32:"~", 0x33:";",
0x34:"'", 0x36:",", 0x37:"."
}
nums = []
# tshark -r chall.pcap -T fields -e usbhid.data > usb.txt
keys = open('./usb.txt')
for line in keys:
line = line.strip()
if len(line) < 6:
continue
if line[:2] != '00' or line[4:6] != '00':
nums.append(int(line[4:6],16))
keys.close()
output = ""
for n in nums:
if n == 0:
continue
if n in mappings:
output += mappings[n]
else:
output += ''
```
Well, that went as well as one can imagine. Now we look inside the decoded text, and found a pastebin link (well, at least it's supposed to be.)
```
IINTOO
II CHRROOMME PAASTTEBBIIN.CCOOM/YJRVPAB5 II
TTHHINNKK
```
The issue with this decoder is that it cannot differentiate between capitalized and non-capitalized letters. So we look again to our most loved method of problem-solving, brute-force.
```py
base_url = "https://pastebin.com/raw/"
slug = "YJRVPAB5"
chars = [([c.lower(), c.upper()]) for c in slug]
urls = [base_url + ''.join(p) for p in product(*chars)]
for url in urls:
try:
resp = requests.get(url)
body = resp.text
if ("Not Found" not in body):
print(f"Found: {url}")
break
else:
print(f"Not Found: {url}")
except Exception as e:
continue
```
Now we can find the true pastebin link, maybe now we can find the flag. After running it, the valid pastebin link turns to be: https://pastebin.com/yjrVPaB5. The link is pastebin is key-protected, which turns us back into the decoded dump, and found the key right under where we found the pastebin slug.
```
PAASSWWORRDED
THHO OOOKAAY RAHASIAASEP1284 DDAMNnOOKAY VIM
TODO.TXTnI-
```
Some trial and error, after that we found the valid password to be `rahasiaasep1284`. After that, the flag can be found!

<h4 style="text-align:center">
CTFITB2025{h4ti_h4ti_trafF1c_UsB_ny4_d15ad4p!!!s33_y0u_1n_ctf_c0mmun1ty}
</h4>
<hr>
<h3 style="text-align:center">
Asep Kasep - 493
</h3>
<center>

</center>
The attachment given is a zip file containing 1 file, `chall.ad1`. An image file which can be open using FTK Imager. Doing some basic investiagtion results in finding a file named "I GOT HACKED.txt" which told the story of Asep running a malicious powershell script that results in his computer getting encrypted (cmon man, in big 25 π₯). On the same `Downloads` folder, we also found a `traffic.pcapng` file, filled with Asep's network activity.
<center>

</center>
Opening the capture file in wireshark, we found the call that happened when Asep downloaded the ZIP file, and just export that packet so we can do more investigative work.
<center>

</center>
The ZIP file contains 2 files, `Helpdesk.ps1` the actual malware in the form of a powershell script, and `READ_FIRST.txt` which is filled with instruction on how to run the script.
<center>

</center>
Opening the script in a code editor reveals what the Powershell script does. In short, the script uses a Linear Congruential Generator (LCG) to generate keys that is used to encrypt files recursively.
Here's a snippet of the initial LCG seed, the LCG algorithm, and the function that does the encryption.
```ps
$Global:LCGSeed = [Convert]::ToUInt32('C0FFEE12',16)
function Next-State([uint32]$state){
$tmp = ([uint64]$state * 1103515245) + 12345
$mod = $tmp % 4294967296
return [uint32]$mod
}
function Protect-OneFile {
param([Parameter(Mandatory)][string]$InPath)
$plain = [System.IO.File]::ReadAllBytes($InPath)
$ct = New-Object byte[] ($plain.Length + 6)
$ct[0]=0x48; $ct[1]=0x45; $ct[2]=0x4C; $ct[3]=0x50; $ct[4]=0x01; $ct[5]=0x00 # "HELP"+v1
[uint32]$state = $Global:LCGSeed
for ($i=0; $i -lt $plain.Length; $i++) {
$state = Next-State $state
$k = [byte](($state -shr 24) -band 0xFF)
$ct[6+$i] = $plain[$i] -bxor $k
}
$outPath = "$InPath.enc"
[System.IO.File]::WriteAllBytes($outPath, $ct)
$ok = ([System.IO.File]::Exists($outPath) -and ((Get-Item -LiteralPath $outPath).Length -eq $ct.Length))
if (-not $ok) { throw "Output verification failed (size mismatch)." }
Remove-Item -LiteralPath $InPath -Force
return $outPath
}
```
The encryption process goes like this:
* Program reads a file input and prepares a buffer with the size of the input + 6 bytes, in which place a set of 6 bytes is put as a header `0x48 0x45 0x4C 0x50 0x01 0x00` (The first 4 is HELP in ASCII)
* The LCG is called, the first time using the initial seed `0xC0FFEE12`
* After the initial call, the seed advanced by using the value returned by the LCG function itself.
* The value is then used to XOR with the input (in this case the files that is to be encrypted), and repeat until all of the input is encrypted, each with a new key from the LCG function.
* The input is then saved into the the filesystem using the same name as the original input name, plus a `.enc` extension (input.txt -> input.txt.enc)
* After the encryption process is done, the program verifies the size of the output (accounting extra space for the header) to match the input.
* Lastly, the program deletes any input files that previously got encrypted.
Now that we understand what the script does, time to exploit it's weakness and make a decryptor. When looking closer, one realizes that the LCG algorithm is actually deterministic, meaning if we know the initial seed (which we do), we can make the decryptor!
```python
def lcg_next(state):
return ((state * 1103515245) + 12345) & 0xFFFFFFFF
def decrypt_file(enc_path, out_path):
with open(enc_path, 'rb') as f:
data = f.read()
# Verify header
if data[:6] != b'HELP\x01\x00':
raise ValueError("Invalid encrypted file header")
ciphertext = data[6:]
plaintext = bytearray()
state = 0xC0FFEE12
for byte in ciphertext:
state = lcg_next(state)
key_byte = (state >> 24) & 0xFF
plaintext.append(byte ^ key_byte)
with open(out_path, 'wb') as f:
f.write(plaintext)
prob_folder = "/home/lirosphere/ctf/contests/hmifcomquals/foren/Asep Kasep"
decrypt_file(f'{prob_folder}/Input.enc', f'{prob_folder}/Output')%
```
Thanks to your favorite LLM for the decryptor, now what file should we decrypt? The `SECRET.zip.enc` and `SECRET_README.txt.enc` in the `Documents` folder looks like a good target.
```
SECRET_README.txt
so uhh...
if I ever forget my super SECRET zip password,
donβt worry, it's the same as my Instagram password π
```
Oh, looks like we're on the correct track, the ZIP file is password-protected, but that doesn't stop us from peeking into what's inside.
<center>

</center>
4 PNG files, maybe the flag is in there? Time to find the password, the SECRET_README.txt mentioned it's the same as Asep's Instagram password. This leads us into extracting the saved passwords of browsers, since to access instagram, you'll need to use a browser. After some more sniffing around, we find Asep uses Firefox, a browser that saves logins (usernames and passwords), and already has a [decryptor](https://github.com/unode/firefox_decrypt), we can just extract the profile folder and find the password.
<center>

</center>
Now that we got the password, let's unzip the `SECRET.zip` file and see what's inside.
<center>
| <center>1.png</center> | <center>2.png</center> | <center>3.png</center> | <center>4.png</center> |
| -------- | -------- | -------- | --- |
|  |  |  |  |
</center>
All that's left is to construct the flag, and that's it!
<h4 style="text-align:center">
CTFITB2025{A53p_9oB10k_G4mp4n9_k3n4_t1pu}
</h4>
<hr>
<br>
<h2 style="text-align:center">
Reverse Engineering
</h2>
<h3 style="text-align:center">
Yamada Ransom - 100
</h3>
<center>

</center>
We're given a ZIP file containing an encrypted flag aptly named `flag.encrypted` and an .exe file `yamada.exe`. From the name of the problem, one should not just run the executable, so we throw it into your favorite decompiler.
The executable is a simple encryption "malware" that uses AES to encrypt a file named `flag.txt` into `flag.encrypted`, time to put your favorite LLM into use again.
```python
from Crypto.Cipher import AES
from Crypto.Hash import SHA256
from Crypto.Util.Padding import unpad
def solve():
key = b"ThisIsTheEncryptKey"
iv = b'\x00' * 16
hasher = SHA256.new(key)
derived_key = hasher.digest()
with open("flag.encrypted", "rb") as f:
encrypted_data = f.read()
cipher = AES.new(derived_key, AES.MODE_CBC, iv)
decrypted_data = cipher.decrypt(encrypted_data)
try:
unpadded_data = unpad(decrypted_data, AES.block_size)
except ValueError:
unpadded_data = decrypted_data.rstrip(b'\0')
print("Decrypted flag:")
print(unpadded_data.decode(errors='ignore'))
if __name__ == "__main__":
solve()
```
:::info
π The part that took the most time was actually looking for the correct IV. Note the `\x00*16` used as the IV, in the executable exists a string `IVCanObfuscation`, but returns a partially broken flag when used.
Not sure if this was a red-herring, intended, or an oversight.
:::
<h4 style="text-align:center">
CTFITB2025{omg99_ry0_d0nt_g1v3_th3m_m4lwar3_alr3ady!_:shakemyhead:}
</h4>
<hr>
<h3 style="text-align:center">
TBFO STIMA - 500
</h3>
<center>

</center>
The attachment given is a program that prompts the user for an input, which can only consists of the characters "c", "t", and "f".
<center>

</center>
Trying to decompile the program directly results in a suboptimal results, maybe it has some sort of anti-tamper?
<center>

</center>
Well, that solves it, time to unpack it before we continue on with our journey. After doing that (and some decompiling), time to put your favorite LLM to use (again).
```python
import struct
from collections import deque
def decrypt_byte(encrypted, key2, index, key4, key5):
return ((key4 * index + key5) ^ key2 ^ encrypted) & 0xFF
def decrypt_data(data, key2, key4, key5):
result = []
for i, byte in enumerate(data):
decrypted = decrypt_byte(byte, key2, i, key4, key5)
result.append(decrypted)
return bytes(result)
with open('chall_unpacked', 'rb') as f:
f.seek(0x2100)
params_encrypted = f.read(6)
f.seek(0x2120)
emission_encrypted = f.read(0x6c)
f.seek(0x21a0)
transition_encrypted = f.read(0x144)
print("[*] Decrypting tables...")
transition_decrypted = decrypt_data(transition_encrypted, 0x9d, 0x3d, 7)
emission_decrypted = decrypt_data(emission_encrypted, 0xc3, 0x55, 0x11)
params_decrypted = decrypt_data(params_encrypted, 0xa7, 0x21, 0x42)
transition_table = []
for i in range(0, len(transition_decrypted), 2):
value = struct.unpack('<H', transition_decrypted[i:i+2])[0]
transition_table.append(value)
emission_table = []
for i in range(0, len(emission_decrypted), 2):
value = struct.unpack('<H', emission_decrypted[i:i+2])[0]
emission_table.append(value)
print(f"[*] Transition table entries: {len(transition_table)}")
print(f"[*] Emission table entries: {len(emission_table)}")
print(f"[*] Number of states: {len(transition_table) // 3}")
num_states = len(transition_table) // 3
alphabet = ['c', 't', 'f']
def get_next_state(current_state, input_char_idx):
"""Get next state from transition table"""
index = current_state * 3 + input_char_idx
if index < len(transition_table):
return transition_table[index]
return None
graph = {}
for state in range(num_states):
graph[state] = []
for char_idx, char in enumerate(alphabet):
next_state = get_next_state(state, char_idx)
if next_state is not None and next_state < num_states:
graph[state].append((next_state, char, char_idx))
print(f"[*] FSM Graph built with {len(graph)} states")
def find_path(start_state, target_state):
"""BFS to find shortest path"""
queue = deque([(start_state, [])])
visited = {start_state}
while queue:
state, path = queue.popleft()
if state == target_state:
return path
if state in graph:
for next_state, char, char_idx in graph[state]:
if next_state not in visited:
visited.add(next_state)
queue.append((next_state, path + [(char, char_idx, state, next_state)]))
return None
print("[*] Finding path from state 1 to state 19...")
path = find_path(1, 19)
if path is None:
print("[-] No path found!")
exit(1)
print(f"[+] Path found! Length: {len(path)}")
input_string = ''.join([char for char, _, _, _ in path])
print(f"[+] Input string: {input_string}")
def simulate_fsm(input_chars):
"""Simulate FSM and generate output"""
state = 1
output = []
init_param1 = decrypt_byte(0x30, 0x5e, 0, 0x12, 0x34)
init_param2 = decrypt_byte(0xbd, 0x5e, 1, 0x12, 0x34)
init_params = bytes([init_param1, init_param2])
for char, char_idx, old_state, new_state in input_chars:
old_emission = emission_table[old_state] if old_state < len(emission_table) else 0
new_emission = emission_table[new_state] if new_state < len(emission_table) else 0
params_val = struct.unpack('<H', params_decrypted[char_idx*2:char_idx*2+2])[0] if char_idx*2+1 < len(params_decrypted) else 0
old_b1 = old_emission & 0xFF
old_b2 = (old_emission >> 8) & 0xFF
new_b1 = new_emission & 0xFF
new_b2 = (new_emission >> 8) & 0xFF
p_b1 = params_val & 0xFF
p_b2 = (params_val >> 8) & 0xFF
init_b1 = init_params[0]
init_b2 = init_params[1]
out_b1 = (((new_b1 ^ old_b1) + p_b1) ^ init_b1) & 0xFF
out_b2 = (((new_b2 ^ old_b2) + p_b2) ^ init_b2) & 0xFF
output.append(out_b1)
output.append(out_b2)
state = new_state
return bytes(output)
output = simulate_fsm(path)
print(f"[+] Output length: {len(output)} bytes")
if len(output) >= 36:
flag = output[:36].decode('ascii', errors='ignore')
print(f"[+] Flag: {flag}")
else:
print(f"[-] Output too short: {len(output)} < 36")
print(f"[*] Raw output: {output.hex()}")
```
:::spoiler
:abcd: The program is a [Mealy Machine](https://en.wikipedia.org/wiki/Mealy_machine). I feel dirty just saying "put it through an LLM" but that is what i did :p
The solver extract encrypted data from the binary, and builds a graph from that data, pathfinds into the correct sequence, and voila.
Don't @ me pls n thx
:::
<center>

</center>
<h4 style="text-align:center">
CTFITB2025{j4go_tbF0_d4n_st1M4_n1cH}
</h4>
<hr>
<h3 style="text-align:center">
Cookie Monster - 500
</h3>
<center>

</center>
Ah, a game reverse-engineering, thank you my [goat](https://cryptohack.org/user/Etynso/) <3. Anyway, enough glazing, let's dive in!
<center>

</center>
`game.x86_64` and `game.pck`, all these files reeks of Godot (my beloved), time to bring out the big guns ([GDRE Tools](https://github.com/GDRETools/gdsdecomp)).
<center>

</center>
Aw, it's encrypted, good thing each Godot game that is encrypted packed with the key! "Doesn't that defeat the purpose of the encryption?" Well, how else are people gonna play the game? [Food for thought](https://www.reddit.com/r/godot/comments/1j1kt1h/is_setting_an_encryption_key_for_compilation/).
Well, only one way available now, find the key inside the binary!
<center>

</center>
Since Godot is open-source, we can look on how it does it's encryption. Mostly is just this [line](https://github.com/godotengine/godot/blob/d3b052df8f0c3051bb488b71eb3f1ba2ce511060/core/io/file_access_pack.cpp#L300) here. So we just need to sniff around in ghidra near the errors with that exact messages, and find the variable referencing the key.
There's also a [YouTube Tutorial](https://www.youtube.com/watch?v=fWjuFmYGoSY) on this. Goodluck :D
::: warning
My Ghidra broke while writing this, so no screenshots. @lirosphere on discord if you want more information on how to find the key :)
:::
After getting the encryption key and extracting the files, we're presented with the core logic of the game inside the `main.gd`
<center>

</center>
And of course it's obfuscated, nothing's ever that simple, well you know the drill, put it through your favorite LLM.
```python
#!/usr/bin/env python3
"""
Cookie Monster CTF Challenge Solver
This script simulates the button-clicking game logic to find the correct
sequence of 100 button clicks, then uses that sequence to decrypt the flag.
"""
import hashlib
import struct
# Constants from the deobfuscated code
ENCRYPTED_FLAG = "99901a046bbf02b1b9ca54230460017fd90e85e91c00bba0366ab2b117261486f60296fe565ff9f80d78dbef7f5f3e8f0bbdb4b40102"
CHACHA20_NONCE = "514bce624f016e0b6e99fd3c"
# Expected MD5 hashes (100 total)
EXPECTED_MD5_HASHES = [
"cfcd208495d565ef66e7dff9f98764da", "35f4a8d465e6e1edc05f3d8ab658c551",
"1c383cd30b7c298ab50293adfecb7b18", "b53b3a3d6ab90ce0268229151c9bde11",
"c20ad4d76fe97759aa27a0c99bff6710", "3c59dc048e8850243be8079a5c74d079",
"37693cfc748049e45d87b8c7d8b9aacd", "aab3238922bcc25a6f606eb525ffdc56",
"ed3d2c21991e3bef5e069713af9fa6ca", "28dd2c7955ce926456240b2ff0100bde",
"a684eceee76fc522773286a895bc8436", "8613985ec49eb8f757ae6439e879bb2a",
"c16a5320fa475530d9583c34fd356ef5", "6c8349cc7260ae62e3b1396831a8398f",
"ad61ab143223efbc24c7d2583be69251", "d82c8d1619ad8176d665453cfb2e55f0",
"33e75ff09dd601bbe69f351039152189", "1ff1de774005f8da13f42943881c655f",
"8e296a067a37563370ded05f5a3bf3ec", "d2ddea18f00665ce8623e36bd4e3c7c5",
"e369853df766fa44e1ed0ff613f563bd", "02e74f10e0327ad868d138f2b4fdd6f0",
"a87ff679a2f3e71d9181a67b7542122c", "7f39f8317fbdb1988ef4c628eba02591",
"f033ab37c30201f73f142449d037028d", "2838023a778dfaecdc212708f721b788",
"735b90b4568125ed6c3f678819b6e058", "d67d8ab4f4c10bf22aa353e27879133c",
"d3d9446802a44259755d38e6d163e820", "98f13708210194c475687be6106a3b84",
"17e62166fc8586dfa4d1bc0e1742c08b", "9778d5d219c5080b9a6a17bef029331c",
"6ea9ab1baa0efb9e19094440c317e21b", "eccbc87e4b5ce2fe28308fd9f2a7baf3",
"d645920e395fedad7bbbed0eca3fe2e0", "9bf31c7ff062936a96d3c8bd1f8f2ff3",
"9a1158154dfa42caddbd0694a4e9bdc8", "f7177163c833dff4b38fc8d2872f1ec6",
"072b030ba126b2f4b2374f342be9ed44", "a5771bce93e200c36f7cd9dfd0e5deaa",
"f4b9ec30ad9f68f89b29639786cb62ef", "fbd7939d674997cdb4692d34de8633c4",
"93db85ed909c13838ff95ccfa94cebd9", "fc490ca45c00b1249bbe3554a4fdf6fb",
"32bb90e8976aab5298d5da10fe66f21d", "7647966b7343c29048673252e490f736",
"45c48cce2e2d7fbdea1afc51c7c6ad26", "8f14e45fceea167a5a36dedd4bea2543",
"34173cb38f07f89ddbebc2ac9128303f", "ea5d2f1c4608232e07d3aa3d998e5135",
"6f4922f45568161a8cdf4ad2299f6d23", "d1fe173d08e959397adf34b1d77e88d7",
"19ca14e7ea6328a42e0eb13d585e4c22", "c74d97b01eae257e44aa9d5bade97baf",
"ac627ab1ccbdb62ec96e702f07f6425b", "642e92efb79421734881b53e1e1b18b6",
"98dce83da57b0395e163467c9dae521b", "e2c420d928d4bf8ce0ff2ec19b371514",
"3295c76acbf4caaed33c36b1b5fc2cb1", "54229abfcfa5649e7003b83dd4755294",
"72b32a1f754ba1c09b3695e0cb6cde7f", "e4da3b7fbbce2345d7772b0674a318d5",
"92cc227532d17e56e07902b254dfad10", "c0c7c76d30bd3dcaefc96f40275bdc0a",
"182be0c5cdcd5072bb1864cdee4d3d6e", "d09bf41544a3365a46c9077ebb5e35c3",
"2a38a4a9316c49e5a833517c45d31070", "44f683a84163b3523afe57c2e008bc8c",
"a1d0c6e83f027327d8461063f4ac58a6", "fe9fc289c3ff0af142b6d3bead98a923",
"a5bfc9e07964f8dddeb95fc584cd965d", "e2ef524fbf3d9fe611d5a8e90fefdc9c",
"093f65e080a295f8076b1c5722a46aa2", "c51ce410c124a10e0db5e4b97fc2af39",
"b6d767d2f8ed5d21a44b0e5886680cb9", "70efdf2ec9b086079795c442636b55fb",
"6512bd43d9caa6e02c990b0a82652dca", "68d30a9594728bc39aa24be94b319d21",
"a3f390d88e4c41f2747bfa2f1b5f87db", "67c6a1e7ce56d3d6fa748ab6d9af3fd7",
"03afdbd66e7929b125f8597834fa83a4", "43ec517d68b6edd3015b3edc9a11367b",
"1f0e3dad99908345f7439f8ffabdffc4", "3ef815416f775098fe977004015c6193",
"66f041e16a60928b05a7e228a89c3799", "c4ca4238a0b923820dcc509a6f75849b",
"f457c545a9ded88f18ecee47145a72c0", "6364d3f0f495b6ab9dcf8d3b5c6e0b01",
"d9d4f495e875a2e075a1a4a6e1b9770f", "c9f0f895fb98ab9159f51fd0297e236d",
"c81e728d9d4c2f636f067f89cc14862c", "14bfa6bb14875e45bba028a21ed38046",
"7cbbc409ec990f19c78c75bd1e06f215", "9f61408e3afb633e50cdf1b20de6f466",
"3416a75f4cea9109507cacd8e2f2aefc", "26657d5ff9020d2abefe558796b99584",
"c7e1249ffc03eb9ded908c236bd1996d", "1679091c5a880faf6fb5e6087eb1b2dc",
"4e732ced3463d06de0ca9a15b6153677", "812b4ba287f5ee0bc9d43bbf5bbe87fb"
]
def compute_md5(text: str) -> str:
"""Compute MD5 hash of a string"""
return hashlib.md5(text.encode()).hexdigest()
def extract_index_from_hash(hash_str: str) -> int:
"""
Extract an index from the last 4 bytes of an MD5 hash.
Takes characters 24-31 (last 8 hex chars = last 4 bytes).
"""
last_8_chars = hash_str[24:32]
# Convert hex string to integer (big-endian interpretation)
result = int(last_8_chars, 16)
return result
def build_hash_to_button_map():
"""
Build a reverse lookup table mapping MD5 hashes to button IDs.
Since the hashes are MD5 of button IDs (0-99), we can pre-compute them.
"""
hash_map = {}
for button_id in range(100):
hash_val = compute_md5(str(button_id))
hash_map[hash_val] = button_id
return hash_map
def find_correct_sequence():
"""
Simulate the game logic to find the correct sequence of button clicks.
Returns:
List of button IDs in the correct order
"""
print("[*] Building hash-to-button lookup table...")
hash_to_button = build_hash_to_button_map()
print("[*] Simulating game logic to find correct sequence...")
# Start with all expected hashes
remaining_hashes = EXPECTED_MD5_HASHES.copy()
current_index = 0
correct_sequence = []
step = 0
while remaining_hashes:
step += 1
# Get the expected hash at current position
expected_hash = remaining_hashes[current_index]
# Look up which button ID has this hash
if expected_hash not in hash_to_button:
print(f"[!] ERROR: Hash {expected_hash} not found in lookup table!")
return None
button_id = hash_to_button[expected_hash]
# Add to sequence
correct_sequence.append(button_id)
# Remove this hash from remaining
remaining_hashes.pop(current_index)
# If no more hashes, we're done
if not remaining_hashes:
break
# Calculate next index from the hash
next_index_raw = extract_index_from_hash(expected_hash)
current_index = next_index_raw % len(remaining_hashes)
print(f"[+] Found complete sequence of {len(correct_sequence)} buttons!")
return correct_sequence
def chacha20_quarter_round(state, a, b, c, d):
"""ChaCha20 quarter round operation"""
state[a] = (state[a] + state[b]) & 0xFFFFFFFF
state[d] = ((state[d] ^ state[a]) << 16 | (state[d] ^ state[a]) >> 16) & 0xFFFFFFFF
state[c] = (state[c] + state[d]) & 0xFFFFFFFF
state[b] = ((state[b] ^ state[c]) << 12 | (state[b] ^ state[c]) >> 20) & 0xFFFFFFFF
state[a] = (state[a] + state[b]) & 0xFFFFFFFF
state[d] = ((state[d] ^ state[a]) << 8 | (state[d] ^ state[a]) >> 24) & 0xFFFFFFFF
state[c] = (state[c] + state[d]) & 0xFFFFFFFF
state[b] = ((state[b] ^ state[c]) << 7 | (state[b] ^ state[c]) >> 25) & 0xFFFFFFFF
def chacha20_block(key, nonce, counter):
"""Generate a single ChaCha20 block (64 bytes)"""
# ChaCha20 constants: "expand 32-byte k"
constants = [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]
# Initialize state (16 x 32-bit words)
state = constants.copy()
# Add key (8 words, little-endian)
for i in range(0, 32, 4):
state.append(struct.unpack('<I', key[i:i+4])[0])
# Add counter (1 word)
state.append(counter & 0xFFFFFFFF)
# Add nonce (3 words, little-endian)
for i in range(0, 12, 4):
state.append(struct.unpack('<I', nonce[i:i+4])[0])
# Save initial state
initial_state = state.copy()
# Perform 20 rounds (10 double rounds)
for _ in range(10):
# Column rounds
chacha20_quarter_round(state, 0, 4, 8, 12)
chacha20_quarter_round(state, 1, 5, 9, 13)
chacha20_quarter_round(state, 2, 6, 10, 14)
chacha20_quarter_round(state, 3, 7, 11, 15)
# Diagonal rounds
chacha20_quarter_round(state, 0, 5, 10, 15)
chacha20_quarter_round(state, 1, 6, 11, 12)
chacha20_quarter_round(state, 2, 7, 8, 13)
chacha20_quarter_round(state, 3, 4, 9, 14)
# Add initial state
keystream = bytearray()
for i in range(16):
word = (state[i] + initial_state[i]) & 0xFFFFFFFF
keystream.extend(struct.pack('<I', word))
return bytes(keystream)
def chacha20_decrypt(key, nonce, ciphertext):
"""Decrypt data using ChaCha20 stream cipher"""
assert len(key) == 32, "Key must be 32 bytes"
assert len(nonce) == 12, "Nonce must be 12 bytes"
plaintext = bytearray()
block_counter = 0
offset = 0
while offset < len(ciphertext):
# Generate keystream block
keystream_block = chacha20_block(key, nonce, block_counter)
# XOR ciphertext with keystream
chunk_size = min(64, len(ciphertext) - offset)
for i in range(chunk_size):
plaintext.append(ciphertext[offset + i] ^ keystream_block[i])
block_counter += 1
offset += chunk_size
return bytes(plaintext)
def decrypt_flag(correct_sequence):
"""
Use the correct sequence to decrypt the flag.
Args:
correct_sequence: List of button IDs in order
Returns:
Decrypted flag as a string
"""
print("\n[*] Decrypting flag...")
# Convert sequence to string (as the game does)
sequence_str = str(correct_sequence)
print(f"[*] Sequence string: {sequence_str[:100]}...")
# Compute SHA256 of the sequence string to get the key
key_hash = hashlib.sha256(sequence_str.encode()).hexdigest()
print(f"[*] Key (SHA256 hash): {key_hash}")
# Convert hex strings to bytes
key = bytes.fromhex(key_hash)
nonce = bytes.fromhex(CHACHA20_NONCE)
ciphertext = bytes.fromhex(ENCRYPTED_FLAG)
print(f"[*] Key length: {len(key)} bytes")
print(f"[*] Nonce length: {len(nonce)} bytes")
print(f"[*] Ciphertext length: {len(ciphertext)} bytes")
# Decrypt using ChaCha20
plaintext = chacha20_decrypt(key, nonce, ciphertext)
# Convert to ASCII string
flag = plaintext.decode('ascii', errors='ignore')
return flag
def main():
print("=" * 70)
print("Cookie Monster CTF Challenge Solver")
print("=" * 70)
# Step 1: Find the correct sequence
correct_sequence = find_correct_sequence()
if not correct_sequence:
print("[!] Failed to find correct sequence!")
return
print(f"\n[+] Correct sequence found!")
print(f"[*] First 20 buttons: {correct_sequence[:20]}")
print(f"[*] Last 20 buttons: {correct_sequence[-20:]}")
# Step 2: Decrypt the flag
flag = decrypt_flag(correct_sequence)
print("\n" + "=" * 70)
print("[+] FLAG DECRYPTED:")
print(f" {flag}")
print("=" * 70)
if __name__ == "__main__":
main()
```
Well that went better than expected, the encryption uses [ChaCha20](https://en.wikipedia.org/wiki/ChaCha20-Poly1305) (Add-Rotate-XOR). The solver brute-forces the correct sequences of buttons to be pressed, since the script stores the order (Each button number MD5 hashed). The buttons are numbered from 0-99, so they could be pre-computed, after that just a matter of finding the correct sequence.
<center>

</center>
<h4 style="text-align:center">
CTFITB2025{y34_60d07_15_pr377y_345y_70_r3v3r53_tbh_GG}
</h4>
<hr>
<br>
<h2 style="text-align:center">
Cryptography
</h2>
<h3 style="text-align:center">
Easy Hash - 200
</h3>
A Cryptography solve? impossible! Not.... Well this one is just pure LLM madness. We're given a python script.
```python
#!/usr/bin/env python3
from hashlib import sha256
import base64
import os
from libnum import s2n
from secret import FLAG
assert len(FLAG) == 78
KEY = os.urandom(32)
def get_hmac(data: bytes) -> str:
return sha256(KEY + data).hexdigest()
def parse_token(token: str) -> dict:
token_data, token_hmac = base64.b64decode(token).split(b":::hmac=")
if get_hmac(token_data) != token_hmac.decode('latin-1'):
return None
user_data = dict()
for part in token_data.decode('latin-1').split(":::"):
key, value = part.split("=")
user_data[key] = value
return user_data
def register_user():
name = input("Who are you?\n>>> ")
user_data = {"user_id": 0, "name": name, "authorized": "false"}
token = ":::".join(f"{key}={value}" for key, value in user_data.items())
secure_token = f"{token}:::hmac={get_hmac(token.encode())}"
encoded_secure_token = base64.b64encode(secure_token.encode()).decode('latin-1')
print("Your access token: ", encoded_secure_token)
def login_user():
user_data = parse_token(input("Enter access token: "))
if user_data is None:
print("Unverified login detected :(")
return
print(f"Hello {user_data['name']}, why don't you stay and relax here? [https://youtu.be/vy63u2hKoPE?si=CI0Fl5xu4sVj2DbK]")
def request_secret():
user_data = parse_token(input("Enter access token: "))
if user_data is None:
print("Unverified login detected")
return
if user_data["authorized"] == "true":
print("Hmmm looks forged to me...")
user_id = int(user_data["user_id"], 2)
print(f"I'm not gonna give the secret right away...\n{sha256(FLAG[user_id : user_id + 3].encode()).hexdigest()}")
else:
print("Uh oh :-(")
def main():
while True:
print("1. Register\n2. Login \n3. Request Secret ;)\n4. Exit")
try:
choice = int(input("Enter your choice: "))
if choice == 1:
register_user()
elif choice == 2:
login_user()
elif choice == 3:
request_secret()
elif choice == 4:
break
else:
print("Invalid choice")
except Exception:
print(f"Oh no...")
if __name__ == "__main__":
main()
```
Putting it through your favorite LLM (for the nth time) explained that it's vulnerability is on:
```python
def get_hmac(data: bytes) -> str:
return sha256(KEY + data).hexdigest()
```
A Hash Length Extension Attack, a secure HMAC construction should be resistant to this, but the way it's constructed using SHA256, we can extend the original hash and add our data on it.
The solver is 2 files, so here's the [gist](https://gist.github.com/PikaProgram/9c84ca86b42a9450847bb011f1e781ee): we add the property `authorized=true` in order to leak a portion of the flag. Which is repeated until all the flag bytes are leaked, and it's just a matter of reconstructing it.
<center>

</center>
Uhmm, that doesn't seems like a valid flag. Well, time for some good old trial and error, and voila.
<h4 style="text-align:center">
CTFITB2025{biar_gak_bosen_number_theory_terus____non_math_lebih_seru_juga_loh}
</h4>
<br>
<h2 style="text-align:center">
Misc
</h2>
<h3 style="text-align:center">
Irony - 100
</h3>
<center>

</center>
Funny lore, let's start by search "MrBert" on your favorite search engine.
<center>

</center>
Stumbled right into his Instagram, let's try `CTFITB2025{Namsun_Bertin}`, aaaaand nope, that's not his full name i guess. Let's dig deeper, maybe by searching "Namsun Bertin".
A [news article](https://kumparan.com/kumparanbisnis/waspada-marak-modus-penipuan-pakai-struk-atm-palsu-masyarakat-harus-teliti-1viTDe7mW7k), and what's inside? "Namsum Bertin Sembiring", well that wasn't too hard.
<center>

</center>
<h4 style="text-align:center">
CTFITB2025{Namsun_Bertin_Sembiring}
</h4>
<hr>
<h3 style="text-align:center">
Pulkam - 285
</h3>
<center>

</center>
Oh no, it's those kind of challenges. Buckle up, this is gonna take a while. [Draw some circles](https://www.mapdevelopers.com/draw-circle-tool.php) <3
<center>

</center>
After some town lookups and circle adjustments, turns out Pasilihan was the correct one. So let's figure out it's identity.
It's on [Sumatera Barat, Indonesia](https://id.wikipedia.org/wiki/Pasilihan,_X_Koto_Diatas,_Solok), The Village Head Name is [Yonhi Nofri](https://www.baritonagarinews.com/2025/09/penyampaian-nota-keuangan-dan-penetapan.html).
<center>

</center>
So we just need to construct the valid flag. And my job here is done.
<h4 style="text-align:center">
CTFITB2025{Pasilihan_YonhiNofri_SumatraBarat_Indonesia}
</h4>
<h2 style="text-align:center">
Afterword
</h2>
<center>

Any lack of concise explanation on any problems should and will be blamed on doing this writeup 3 hours before the Linear Algebra Quiz and on 4 hours of sleep. - CaitViLover.
@LiroSphere on Discord, hmu
</center>