2026 AIS3 pre-exam writeup
2026 AIS3 pre-exam writeup
先丟結果
Writeup
Welcome
Category: Welcome
題目敘述
欸six seven🗣️🗣️🔥🔥🔥
欸six seven🗣️🗣️🔥🔥🔥
six!six!seven🥰🥰
欸six seven🗣️🗣️🔥🔥🔥
阿公67↗️
阿公阿公67↘️↗️
阿公67↗️
阿公67↗️
阿公阿公67↘️↗️
阿公!!🥰🥰🥰
67!
阿公阿公67↘️↗️
six seven🗣️🗣️🔥🔥🔥
Scan the qrcode to get the flag!
https://ais32026scanme.pwn2ooown.tech/
Author: pwn2ooown + trianglesnake
解題過程
點進連結 會有一個Streming QRcode
用手機掃描會轉跳到以下連結
https://qrss.netlify.app/scan
接著用手機掃描一陣子 直到進度條跑滿
就會跳出這張照片

就照著上面打就好了
flag:
1 | |
想在雪中來杯下午茶嗎?
Category: misc OSINT
題目敘述
雪,真美呀~可以找找這是哪裡嗎?

PS: 這張圖跟你要 OSINT 的無關,只是配圖,請看附件 :)
Flag Format: AIS3{緯度-經度} (For Example: AIS3{78.891-123.456},經緯度無條件捨去到小數點第三位)
Author: naup96321
附件內容
內包含一張平交道的照片
解題過程
其實點近來第一眼就看到這個禁止讓狗狗大便!的牌子
底下有寫是由豐鄉町架設的牌子
讓我們google一下 這是位於滋賀縣的某個小鄉鎮
接著讓我們看看平交道可能會出現的地方,這裡總共有兩個鐵路一條是東海道新幹線可以先排除(高架不會有平交道),再來就是近江鐵道湖東近江路線,然後沿著道路跟鐵道的交接處找,就可以找到
flag:
1 | |
Jail
Category: Misc
題目敘述
(PY)-JAIL ? http://chals1.ais3.org:10001/
附件內容
題目提供一個網站連結。進入網站後,頁面會直接顯示 server side 的 Python 原始碼,沒有另外提供壓縮檔、執行檔或其他附件。
核心程式碼如下:
1 | |
解題過程
1. 觀察網站原始碼
進入網站後,可以看到伺服器直接把自己的 Python 原始碼印出來。從程式碼中可以先注意到幾個重點:
- 使用者輸入會從
request.form["code"]取得 - blacklist 擋掉了
()_[]{}.@# - 使用者輸入不是直接丟進
exec(),而是被寫進一個新檔案 - 檔案會被
chmod +x後直接執行
關鍵程式碼在這裡:
1 | |
這邊最值得注意的是,shebang 後面沒有補換行,而是直接把使用者輸入 d 接上去。
所以這題的突破口不一定是傳統 Python jail 的語法繞過,而可能是利用 shebang 本身的行為。
2. 發現 shebang 可以被注入參數
原本的 shebang 是:
1 | |
但因為程式沒有在後面加上換行,所以如果送出的 code 開頭是:
-Wignorecoding:unicode-escape
實際寫出的檔案第一行就會變成:
1 | |
這一行會同時產生兩個效果:
- 作業系統執行檔案時,會把
-Wignorecoding:unicode-escape當成 Python 啟動參數 - Python 讀取 source code 時,第一行中的
coding:unicode-escape也會被當成 encoding cookie
也就是說,第一行可以同時被當成 shebang option injection,以及 Python source encoding 宣告。
3. 利用 unicode-escape 繞過 blacklist
題目 blacklist 擋掉了這些字元:
1 | |
如果直接寫:
1 | |
會因為包含 (、)、. 等字元而被擋下來。
但只要讓 Python 使用 unicode-escape 解析 source code,就可以用 \xNN 的形式表示被 ban 的符號。例如:
1 | |
Python 讀檔時會把它解碼成:
1 | |
其中幾個重要轉換如下:
\x28會變成(\x29會變成)\x2e會變成.
這裡也要注意,使用的是 unicode-escape,不是 unicode_escape,因為底線 _ 本身也在 blacklist 裡面。
4. 組出最終 payload
把前面的想法組合起來,送出的 code 內容如下:
1 | |
伺服器實際寫出的檔案會變成:
1 | |
執行時,第一行會讓 Python 以 unicode-escape 解析檔案內容,因此第二行就會等價於:
1 | |
這樣就可以讀取 /flag 並印出結果。
5. 撰寫 exploit script
最後用 requests 對網站送出 POST request:
1 | |
執行後,伺服器會建立並執行該 Python 檔案。由於 payload 成功繞過 blacklist,最後就能讀到 /flag 的內容。
Flag
1 | |
Jail Revenge
Category: Misc
題目敘述
你把我的 jail 弄壞了,我也要傷了你的什麼東東
http://chals1.ais3.org:10002/
附件內容
題目提供一個網站連結。進入網站後,頁面會直接顯示 server side 的 Python 原始碼,沒有另外提供壓縮檔、執行檔或其他附件。
核心程式碼如下:
1 | |
解題過程
1. 觀察 revenge 版的限制
打開網站後,可以看到伺服器直接把自己的 Python 原始碼顯示出來。和前一題 Jail 相比,這題多了一些限制:
- blacklist 仍然擋
()_[]{}.@# - 使用者輸入會先經過
unicodedata.normalize("NFKC", ...) - 第一行長度必須小於 50
- 輸入來源改成
request.data.decode(),也就是讀 raw body - 程式仍然會把輸入接到 shebang 後面,再寫成檔案執行
關鍵程式碼如下:
1 | |
最後一點很重要,因為這題不是用 request.form 讀表單資料。
如果像一般表單那樣送資料,Flask 可能會先解析 body,導致 request.data 變成空字串,所以 exploit 時要直接送 raw body。
2. 確認 shebang 拼接漏洞仍然存在
雖然 revenge 版加上了 normalize 和長度限制,但真正的漏洞仍然在這行:
1 | |
shebang 原本是:
1 | |
但後面一樣沒有補換行,所以使用者輸入仍然可以直接接在 shebang 同一行。
如果送出的第一行是:
-Wignorecoding:unicode-escape
伺服器實際寫出的第一行就會變成:
1 | |
這行可以同時造成兩個效果:
- 作業系統執行腳本時,會把
-Wignorecoding:unicode-escape當成 Python 啟動參數 - Python 讀取 source code 時,會看到
coding:unicode-escape,並把它當成 source encoding cookie
因此前一題用到的 shebang option injection 加上 source encoding cookie 的技巧,在這題仍然成立。
3. 檢查長度限制是否會擋住 payload
這題多了一個限制:
1 | |
我們要塞進第一行的內容是:
-Wignorecoding:unicode-escape
這段長度只有 30,符合小於 50 的限制。
同時,-W 是合法的 Python CLI 選項,後面的 warning filter 雖然格式看起來不正常,但 Python 不會因此中止執行,而且字串中剛好包含 coding:unicode-escape。
也就是說,這段內容可以同時通過長度限制、當成 Python 啟動參數,並觸發 source encoding。
4. 用 unicode-escape 繞過 blacklist
題目 blacklist 擋掉這些字元:
1 | |
所以不能直接寫:
1 | |
不過只要讓 Python 使用 unicode-escape 當 source encoding,就可以把被 ban 的符號改成 \xNN 的形式:
1 | |
Python 重新讀檔時,會把它解成:
1 | |
其中:
\x28會變成(\x29會變成)\x2e會變成.
這裡要使用 unicode-escape,不能寫成 unicode_escape,因為底線 _ 也在 blacklist 裡面。
5. 組出最終 payload
最後送出的 raw body 如下:
1 | |
伺服器實際寫出的檔案會變成:
1 | |
當 Python 重新解析這份檔案時,第二行就會等價於:
1 | |
因此可以讀取 /flag 的內容並輸出。
6. 撰寫 exploit script
這題的重點是要送 raw body,而不是表單資料。使用 requests 時,可以明確指定 Content-Type: text/plain:
1 | |
實際回傳內容如下:
1 | |
可以看到 flag 被放在輸出中的註解行,因此整理出真正的 flag。
Flag
1 | |
MyGO!!!!! X Ave Mujica 圖庫
Category: Web
題目敘述
我已經想不到要怎麼塞 MyGO 梗了,你來幫我塞
機器人禁止
author: ItisCaleb
附件內容
題目主要提供一個 Web 站台。進站後可以看到圖庫相關功能,包含讀取圖片與上傳圖片:
GET /image?id=...:依照圖片編號讀取圖片POST /upload:上傳圖片
另外,網站的 robots.txt 中出現了以下內容:
1 | |
這個線索表示站台目錄中可能殘留 Subversion 的工作目錄資訊。
解題過程
1. 觀察網站功能與 robots.txt
打開網站後,可以看到首頁前端會把圖片編號帶進 /image?id=${item} 來讀取圖片。
接著查看 robots.txt,內容只有一行:
1 | |
這裡的 .svn 很值得注意,因為 Subversion 工作目錄裡通常會保存版本控管相關資訊,例如檔案路徑、checksum 等資料。如果這些檔案可以被讀取,就可能找到網站目錄內其他敏感檔案。
2. 測試 /image 的 id 參數
先測試 /image 的 id 參數是否會被直接放進 SQL 查詢:
1 | |
這個請求可以正常回傳圖片,代表 id 很可能被直接拼進 SQL 字串中,因此可以往 SQL Injection 的方向測試。
接著使用 UNION SELECT 嘗試讓查詢結果變成任意檔案路徑:
1 | |
請求成功後,網站直接回傳了 app.py 的內容,表示後端不是單純查圖片,而是把 SQL 查詢結果拿去當作檔案路徑讀取。
3. 確認原始碼中的漏洞
讀出 app.py 後,可以看到 /image 的關鍵邏輯:
1 | |
問題點在於 image_id 被直接放進 f-string,沒有使用參數化查詢,所以可以注入 SQL。
同時,查詢結果 cur[0] 又會直接交給 send_file()。因此這個漏洞可以整理成:
id參數存在 SQL InjectionUNION SELECT可以控制查詢出來的檔案路徑- 後端會把該路徑交給
send_file(),形成任意檔案讀取
4. 讀取 .svn/wc.db
既然 robots.txt 提示了 .svn,下一步就是讀取 Subversion 的 working copy 資料庫:
1 | |
下載到 wc.db 後,用 sqlite3 查看其中記錄的檔案資訊:
1 | |
查詢結果中出現一個很可疑的檔名:
1 | |
這表示網站目錄裡很可能存在這個 flag 檔案。
5. 讀出 flag 檔案
最後利用前面確認的任意檔案讀取方式,直接把可疑檔案讀出來:
1 | |
成功取得 flag。整個流程的關鍵是先利用 SQL Injection 控制 send_file() 讀取的路徑,再透過 .svn/wc.db 找到真正的 flag 檔名。
Flag
1 | |
Mass Rapid Transit
Category: Web
題目敘述
AIS 捷運公司(AIS Transit Corporation)官方資訊平台已上線,歡迎旅客註冊使用。
http://chals1.ais3.org:10003/
附件內容
題目主要提供一個 Web 站台。首頁看起來像 Rails 風格的捷運公司網站,功能包含:
- 註冊帳號
- 登入
- 查看與修改個人資料
- 失物招領
- 管理後台
另外,網站的 robots.txt 有提示:
1 | |
這表示網站存在 /admin 管理頁面,但一般使用者正常情況下不應該能直接進入。
解題過程
1. 觀察網站功能與題目名稱
進入網站後,先註冊一個一般帳號並登入,可以看到登入後能進入 /profile 修改個人資料。
整理目前看到的重點:
robots.txt提示有/admin- 一般帳號登入後可以使用
/profile /profile有更新個人資料的表單- 題目名稱是
Mass Rapid Transit
其中題目名稱很明顯在暗示 Mass Assignment。因此可以優先懷疑:後端在更新使用者資料時,可能沒有正確限制哪些欄位可以被使用者修改。
2. 檢查 /profile 的更新請求
登入後打開 /profile,修改個人資料時可以看到請求大致會送出這類參數:
1 | |
這種 user[...] 的參數形式很像 Rails 常見的 nested parameter。如果後端直接把整包 user 參數拿去更新 User 物件,且沒有做 strong parameters 或欄位白名單,就可能讓一般使用者改到不該改的欄位。
3. 嘗試加入 role 欄位升權
接著在原本正常欄位之外,額外加入:
1 | |
完整請求結構大致如下:
1 | |
送出後重新整理 /profile,可以看到帳號身分從原本的一般旅客變成管理員。
這代表後端更新 User 物件時,沒有正確限制可被 assign 的欄位,導致 role 可以被一般使用者直接覆蓋,也就是典型的 Mass Assignment 漏洞。
4. 進入管理後台取得 flag
升權成功後,再打開 robots.txt 提示的管理頁面:
1 | |
這次就可以成功進入管理後台。進入後,在管理後台的內部公告區塊可以看到 flag。
整個利用重點可以簡化成:在 /profile 的更新請求中加入 user[role]=admin,讓自己的帳號被更新成管理員,接著存取 /admin 取得 flag。
Flag
1 | |
Give Me Flag
Category: Web
題目敘述
Free flag for you XDD
Author: Chumy
附件內容
題目提供一份 ASP.NET Core 8 Razor Pages 應用的附件,包含 Docker 設定與 publish 後的程式檔案。
比較重要的檔案如下:
Dockerfiledocker-compose.ymlpublish/GiveMeFlag.dllpublish/Microsoft.Office.Server.Search.Connector.dll- 其他 Microsoft SharePoint / Ceres 相關 DLL
從 docker-compose.yml 可以看到幾個關鍵環境變數:
1 | |
也就是說,服務會把 flag 送到:
1 | |
而且 Host header 會被設定成 flag-dropbox.givemeflag.internal。
解題過程
1. 觀察服務功能
這個網站主要有兩個功能:
- 首頁
/:輸入 IPv4 或 IPv6 位址,服務會向該 IP 的 port 443 發送 HTTPS POST /Support:提供 component preview 功能,可以輸入 template 名稱預覽 UI 卡片
表面上首頁功能很直接,只要架一個 HTTPS collector,讓題目服務把 flag POST 過來即可。
但實際測試時,自簽憑證會造成 TLS 驗證失敗,所以單純架 HTTPS server 還不夠,必須想辦法讓題目服務接受我們的憑證。
2. 反編譯 GiveMeFlag.dll
使用 ilspycmd 反編譯 publish/GiveMeFlag.dll,可以先看到首頁送 flag 的邏輯。
在 FlagDeliveryService 中,服務會依照使用者輸入的 IP 建立 HTTPS endpoint:
1 | |
接著建立 HttpWebRequest:
1 | |
也就是說,實際連線目標會是:
1 | |
但 HTTP Host header 會被覆寫成:
1 | |
POST body 則會包含 flag:
1 | |
3. 確認 TLS 驗證問題
一開始嘗試架設 HTTPS collector 並提交自己的 IP,但會遇到憑證驗證錯誤。
如果憑證的 SAN 是 IP 位址,會出現類似錯誤:
1 | |
後來把憑證 SAN 改成 DNS:flag-dropbox.givemeflag.internal 後,錯誤變成:
1 | |
這代表名稱驗證已經通過,剩下的問題只是自簽憑證不被信任。
這裡可以推得一個重要行為:在這個情境下,.NET 8 的 HttpWebRequest 會用 req.Host 的值,也就是 flag-dropbox.givemeflag.internal 來做憑證名稱驗證,而不是用 URL 裡的 IP。
因此只要能繞過 UntrustedRoot,就可以讓服務成功連到我們的 collector。
4. 分析 /Support 的 Component Preview
接著看第二個功能 /Support。反編譯後可以看到它會用 PreviewComponentResolver 解析使用者輸入的 template。
關鍵邏輯大致如下:
1 | |
其中 template 如果包含 #,# 後面的字串會被當成 descriptor.Metadata,最後直接傳進 Type.GetType()。
格式類似:
1 | |
而 LoadFromApplicationDirectory 會從應用程式目錄載入對應的 DLL:
1 | |
解析出 type 後,程式會檢查它不是 abstract、不是 interface,且有 public 無參數建構子,最後使用:
1 | |
這代表使用者可以指定 publish 目錄中已存在 DLL 裡的型別,讓服務建立該型別的 instance。雖然不能上傳任意 DLL,但可以利用現有 DLL 裡有副作用的型別作為 gadget。
5. 找到可以停用 TLS 驗證的 Gadget
在附件中的 Microsoft.Office.Server.Search.Connector.dll 裡,可以找到這個型別:
1 | |
反編譯後可以看到它的 static constructor 會修改全域 TLS 驗證 callback:
1 | |
這個 gadget 很關鍵,原因是:
ValidateCertificates()永遠回傳true- static constructor 會把這個 callback 加到
ServicePointManager.ServerCertificateValidationCallback - 這個設定會影響同一個 ASP.NET Core process 裡後續的 HTTPS 請求
Activator.CreateInstance(type)在建立 instance 前會先觸發 static constructor
所以只要透過 /Support 成功載入並建立 ExchangeSystemUtility,就可以讓整個 process 後續的 TLS 憑證驗證被停用。
6. 透過 Type Injection 觸發 Gadget
先對 /Support?handler=Preview 發送請求,讓它載入 ExchangeSystemUtility:
1 | |
如果成功,頁面會顯示該型別名稱,代表 Activator.CreateInstance() 已經執行,也代表 static constructor 已經被觸發。
這一步完成後,TLS 憑證驗證就會在同一個 server process 中被全域停用。
7. 架設 HTTPS Collector 並提交 IP
接著在自己的機器上架 HTTPS collector,port 需要是 443,憑證的 SAN 要包含 flag-dropbox.givemeflag.internal。
可以用以下指令產生自簽憑證:
1 | |
然後回到首頁提交 attacker IP:
1 | |
這一步必須和前面的 type injection 在同一個 server process 中完成,因為 ServicePointManager.ServerCertificateValidationCallback 是 process-wide 的狀態,不是永久寫入設定檔。
送出後,collector 會收到題目服務對 /api/flag 的 HTTPS POST,body 類似:
1 | |
8. 使用 solve.py 自動化
最後整理出 exploit script:
1 | |
整理成腳本後,利用流程就是先觸發 /Support 的 gadget,再提交 IP 等 collector 收到 flag。
使用方式如下:
1 | |
腳本會做幾件事:
- 產生 SAN 為
flag-dropbox.givemeflag.internal的自簽憑證 - 在 port 443 啟動 HTTPS collector
- 對
/Support?handler=Preview注入ExchangeSystemUtility - 對首頁提交 attacker IP
- 從 collector 收到的 JSON 中印出 flag
Flag
1 | |
tetris,簡單
Category: Reverse
題目敘述
我知道你很會玩俄羅斯方塊的...能請你幫玩嗎?

Author: naup96321
附件內容
題目提供一個 Linux 執行檔 tetris。
執行後表面上是一個終端機版的俄羅斯方塊遊戲,但實際上 flag 並不是單純靠正常遊玩、累積分數或消除行數取得,而是藏在程式內部沒有被一般遊戲流程觸發的隱藏狀態中。
解題過程
1. 檢查檔案基本資訊
首先使用 file 檢查題目提供的執行檔:
1 | |
輸出結果如下:
1 | |
從這裡可以看到幾個重點:
- 這是一個 Linux x86-64 ELF 執行檔
statically linked代表程式是靜態連結,binary 通常會比較大,裡面也可能包含不少函式庫程式碼stripped代表 symbol 被移除,不能直接靠函式名稱判斷每段程式的用途
因此這題不能只靠函式名稱分析,需要透過反組譯結果、程式流程、交叉參照與可疑資料區段來推論。
2. 執行程式觀察行為
直接執行程式:
1 | |
可以看到它是一個終端機版俄羅斯方塊遊戲。
一開始很容易懷疑 flag 可能和遊戲內條件有關,例如:
- 分數達到某個數字
- 消除特定行數
- 遊戲結束條件
- 題目分數
404 - 特定按鍵或 cheat code
不過實際觀察後,正常遊玩流程並不容易觸發 flag。這代表題目重點應該不是「真的把俄羅斯方塊玩到某個條件」,而是要逆向程式,找出遊戲背後藏起來的邏輯。
3. 使用 strings 尋找明顯線索
接著使用 strings 掃描 binary:
1 | |
也可以直接搜尋和 flag 相關的關鍵字:
1 | |
但沒有直接看到明文 flag。
這表示 flag 很可能不是以明文形式存在程式中,而是被加密後放在資料區,等到特定流程被執行時才會解密。因此接下來需要找出:
- encrypted flag 存在哪裡
- 解密 key 如何產生
- 解密流程在哪裡
- 程式要怎麼進入印出 flag 的流程
4. 逆向程式流程
使用 Ghidra / IDA / Binary Ninja 打開 binary 後,可以先從程式入口、主迴圈和輸出函式交叉參照開始看。
由於這是一個遊戲,正常會有類似下面的流程:
1 | |
但在逆向過程中,可以觀察到程式並不是單純線性執行,而是透過一個類似 state machine 的結構控制流程。
概念上大概像這樣:
1 | |
這個發現很重要,因為 state machine 裡有些 state 並不會從正常遊戲流程自然進入,看起來就是題目刻意藏起來的解題路徑。
5. 找到隱藏的關鍵狀態
進一步分析 state table 後,可以找到兩個關鍵位置:
1 | |
第一段邏輯主要負責:
- 準備解密用的 key
- 從
.data區段複製 encrypted flag blob - 將資料準備給後續解密流程使用
第二段邏輯主要負責:
- 使用前面準備好的 key
- 執行類似 RC4 PRGA 的 keystream 產生流程
- 將 keystream 和 encrypted flag 做 XOR
- 使用
printf印出解密後的 flag
也就是說,程式本身其實已經包含解密器,只是正常遊玩不會進入這段 hidden state。
6. 分析 RC4-like 解密流程
在解密狀態中,可以看到類似 stream cipher 的結構:
1 | |
這段邏輯和 RC4 的 PRGA 很像。
因此可以推論:
- flag 是加密後存在 binary 裡
- 程式執行到 hidden state 後才會解密
- 解密完成後會直接印出 flag
所以不一定要完整重寫解密器,只要讓程式執行到 hidden state,就能讓程式自己把 flag 解出來。
7. Patch state table 進入隱藏流程
因為正常遊戲流程不會進到 hidden state,所以可以透過 patch state table 的方式,讓程式直接跳到關鍵狀態。
原本流程大致是:
1 | |
修改後希望變成:
1 | |
也就是讓 state table 中某個原本指向一般遊戲流程的 entry,改成先進入:
1 | |
完成 key 產生與 encrypted flag 複製後,再進入:
1 | |
執行解密與輸出。
這樣做的原因是:既然程式內部已經有完整的解密流程,就不需要自己重寫演算法,只要改變控制流程,讓程式跑到原本不會被正常觸發的分支即可。
8. 執行 patch 後的程式
Patch 完後重新執行修改後的 binary:
1 | |
程式會直接進入隱藏的解密流程,最後印出:
1 | |
到這裡就可以確認,這題真正的 flag 是由 hidden state 中的 RC4-like 解密流程產生,而不是靠正常遊玩俄羅斯方塊取得。
Flag
1 | |
ㄌㄨㄚˋ
Category: Reverse
題目敘述
ㄌㄨㄚˋ
Author: ShallowFeather
附件內容
題目提供兩個檔案:
1 | |
從檔名可以推測,這題和 Lua bytecode reverse 有關。secret.luac 是題目要分析的 Lua bytecode,而 luac_stripped.exe 則是題目提供的執行器或修改過的 Lua 工具。
解題過程
1. 檢查檔案類型
首先觀察兩個附件的檔案型態:
1 | |
可以得到大致資訊:
1 | |
secret.luac 看起來是 Lua 5.1 bytecode,而 luac_stripped.exe 則是一個 Windows x64 執行檔。
看到 .luac 的時候,直覺會先嘗試用標準 Lua 工具反組譯,例如:
1 | |
或是丟給常見的 Lua decompiler,例如 unluac。
不過實際測試後會發現,標準工具無法正確解析這份 bytecode,代表它應該不是普通的 Lua 5.1 bytecode。
2. 確認標準 Lua 工具無法直接使用
正常的 Lua 5.1 bytecode 可以被標準 luac -l 列出 instruction。
但這題的 secret.luac 經過修改,一般工具解析時會出現不正常的結果。這代表題目真正的重點不是單純反編譯 Lua,而是要先理解題目提供的 luac_stripped.exe 如何讀取與執行這份 bytecode。
逆向 luac_stripped.exe 後,可以觀察到幾個重點:
- 它是修改過的 Lua 5.1 VM / loader
Proto結構被修改過- 每個 function 額外多了一個 byte 作為 decode key
- instruction 的 opcode 不是直接取低 6 bits,而是在執行時動態解碼
也就是說,這題是把混淆放在 Lua bytecode / VM 層,讓現成的 Lua 反編譯工具失效。
3. 理解 Lua 5.1 instruction 格式
Lua 5.1 的 instruction 是 32-bit。
在標準 Lua 5.1 中,opcode 通常放在 instruction 的低 6 bits:
1 | |
一般反組譯器會根據這個 opcode 去判斷指令類型,例如:
1 | |
但這題修改了 opcode 的取得方式,所以如果直接用標準 Lua 5.1 的規則解析,就會把 instruction 判斷錯誤。
這也是為什麼一般 luac -l 或 decompiler 無法直接解出正常邏輯。
4. 逆向 luac_stripped.exe 找出 opcode 解碼方式
在 luac_stripped.exe 中尋找 bytecode loader 與 opcode dispatch 的邏輯後,可以發現它不是直接使用:
1 | |
而是使用類似以下公式還原真正的 opcode:
1 | |
其中各個變數的意義如下:
| 變數 | 意義 |
|---|---|
instruction |
目前讀到的 32-bit Lua instruction |
proto_key |
每個 Lua function / Proto 額外保存的一個 key byte |
pc |
目前 instruction 的位置 |
0x2b |
固定 XOR 常數 |
15 * pc + 17 |
與 PC 相關的動態 key |
& 0x3f |
取出真正的 6-bit opcode |
這裡的重點是:opcode 會受到 proto_key 和 pc 影響,所以同樣的 instruction 在不同 function 或不同位置,解出來的 opcode 可能不同。
5. 分析 Proto 結構修改
標準 Lua 5.1 的 Proto 結構會保存 function 的 bytecode、constant table、nested function、local variable 等資訊。
但這題的 Proto 額外多了一個 byte,這個 byte 會被 VM 當成該 function 的 opcode decode key。
因此如果使用標準 Lua 5.1 parser 直接解析 secret.luac,offset 會錯位,後面的 instruction、constant、nested prototype 都可能被解析錯。
這一步很關鍵,因為要正確還原 bytecode,不能只照標準格式讀,必須依照題目修改過的格式讀出 proto_key。
6. 還原 opcode
根據逆向結果,可以寫出 opcode 解碼核心:
1 | |
其中 pc 是目前 instruction 的 index。
還原後就可以對照 Lua 5.1 的 opcode table,例如:
1 | |
這樣做之後,原本看起來混亂的 Lua bytecode 就能逐步還原成可理解的 instruction。
7. 還原主程式邏輯
將 opcode 還原後,可以看出主程式其實很單純:
1 | |
也就是說,程式會讀取使用者輸入,然後把輸入丟進 check() 函式驗證。
因此真正的 flag 驗證邏輯不在主程式,而是在 check() 裡面。
8. 分析 check() 函式
繼續還原 check() 後,可以看到幾個重要條件:
- 會先檢查輸入長度
- 正確長度是
33 - 內部有兩組 table 作為驗證資料
- 每個字元會經過 XOR / table mapping
- 最後還有一個 accumulator 檢查,值必須等於
229
整理成概念上的程式碼,大概像這樣:
1 | |
這代表 flag 不是直接用明文放在 constant 裡,而是透過逐字元運算驗證。
9. 反推正確輸入
因為每個字元的檢查主要是 XOR 形式,所以可以逐字元反推。
XOR 有一個很重要的特性:
1 | |
也就是說,如果驗證邏輯是:
1 | |
那麼就可以反過來算:
1 | |
這也是為什麼只要還原出 target table 和 key / mapping table,就可以把正確輸入還原回來。
最後反推出的輸入為:
1 | |
10. 驗證結果
將反推出來的字串輸入程式:
1 | |
程式輸出 ok,代表通過驗證。
因此可以確認這就是本題 flag。
Flag
1 | |
哇!金色傳說
Category: Reverse
題目敘述
哇!金色傳說。 來抽武器感受割草的快感吧!
B.zip 是一個 Unity 遊戲, 注意此題跟 Hidden in the Cloak 共用同一個遊戲.
Flag 格式:^AIS3{[A-Za-z_?]+}$
附件內容
題目提供一個壓縮檔 B.zip,解壓後是一個 Unity 遊戲。
解壓後可以看到標準 Unity 遊戲結構:
1 | |
這題是 Unity Mono 類型的 reverse 題,主要遊戲邏輯位於:
1 | |
因此可以直接使用 ILSpy、dnSpy 或 ilspycmd 反編譯 Assembly-CSharp.dll 來分析遊戲邏輯。
解題過程
1. 觀察 Unity 遊戲結構
拿到 B.zip 後,先將壓縮檔解開:
1 | |
可以看到裡面是一個 Unity 遊戲。遇到 Unity reverse 題時,通常會先檢查幾個地方:
Assembly-CSharp.dllglobal-metadata.datStreamingAssets- asset bundle
如果是 Unity Mono,通常遊戲主要邏輯會在 Managed/Assembly-CSharp.dll 裡;如果是 IL2CPP,則會需要搭配 GameAssembly.dll 和 global-metadata.dat 分析。
這題可以在 Reverse1_Data/Managed/Assembly-CSharp.dll 找到完整邏輯,所以可以直接反編譯 C# 程式碼。
2. 反編譯 Assembly-CSharp.dll
使用 ILSpy / dnSpy / ilspycmd 反編譯 Assembly-CSharp.dll 後,可以先搜尋和題目有關的關鍵字,例如:
1 | |
很快可以找到兩個重要 class:
GameManagerGachaServer
其中 GameManager 裡可以看到抽卡伺服器網址:
1 | |
這代表抽卡結果不是完全由本地遊戲計算,而是會把資料送到遠端 API,由伺服器回傳抽卡結果。
這個發現很重要,因為如果抽卡 API 信任 client 傳來的資料,就有機會透過偽造 request 觸發異常結果或隱藏分支。
3. 分析抽卡請求內容
繼續看 GachaServer.RollCoroutine(),可以看到遊戲會組出一份 JSON 並送到伺服器。
關鍵程式碼大致如下:
1 | |
送出的 JSON 包含以下欄位:
spendrateusernamegoldscorekills
其中最值得注意的是 rate。
遊戲正常情況下會用:
1 | |
產生 rate,所以正常玩家送到 server 的 rate 理論上只會落在:
1 | |
這裡就出現一個很可疑的點:如果 server 直接相信 client 傳來的 rate,那就可以自己手動送出超出正常範圍的值,測試會不會觸發隱藏邏輯。
4. 直接測試抽卡 API
知道 API 網址和 JSON 格式後,就可以不用透過遊戲,直接使用 curl 或 Python 對伺服器送 request。
例如先送一個正常範圍的 request:
1 | |
回傳格式大致會包含 weapon 和 armor:
1 | |
這代表直接打 API 是可行的,接下來就可以針對 rate 做邊界測試。
5. 測試異常 rate 範圍
先測試明顯異常的值,例如 rate < 0:
1 | |
這類 request 會拿到類似作弊懲罰的結果,例如:
1 | |
這代表 server 並不是完全沒有檢查,而是對太誇張的值會判定為作弊。
接著測試正常遊戲不會送出的邊界,也就是:
1 | |
例如把 rate 設成 0.3001:
1 | |
這時回傳結果中的 armor.name 會出現 flag:
1 | |
到這裡就可以確認,flag 被藏在特殊 rate 範圍對應的回傳結果中。
6. 為什麼可以這樣做
這題的核心問題在於 server 信任了 client 傳來的抽卡參數。
正常遊戲中,client 只會產生:
1 | |
但因為 request 是由 client 端送出的,只要我們自己構造 HTTP request,就可以把 rate 改成遊戲正常流程不會產生的值。
不過 server 對太誇張的範圍仍然有檢查,例如:
rate < 0rate > 1
這些情況會被判定為作弊,回傳 Wet Noodle I 和 Cheater。
因此真正有效的區間是:
1 | |
在這個區間內,server 沒有把玩家擋掉,反而會進入隱藏分支,把 flag 放在 armor.name 回傳。
7. 撰寫 solver
最後可以寫一支簡單的 Python solver,直接 POST 修改過的 JSON:
1 | |
執行 solver:
1 | |
即可直接取得伺服器回傳,並在 armor.name 中看到 flag。
Flag
1 | |
Hidden in the Cloak
Category: Reverse
題目敘述
主角正在屠殺史萊姆,不過披風好像被黏液沾滿了。
本題和 哇!金色傳說 共用同一個 Unity 遊戲。
Flag 格式:AIS3{[a-hj-kmnp-z0-9_]+}
附件內容
題目提供一個 Unity 遊戲壓縮檔 B.zip,並且題目說明此題和 哇!金色傳說 共用同一個遊戲。
解壓後可以看到 Unity 遊戲常見的結構,其中比較重要的是:
1 | |
一開始比對後可以發現,這題的 Assembly-CSharp.dll 和 哇!金色傳說 是同一份,因此這題的 flag 很可能不是藏在遊戲程式邏輯裡,而是藏在 Unity asset、角色資產或貼圖之中。
解題過程
1. 確認題目和上一題共用遊戲
題目描述提到這題和 哇!金色傳說 共用同一個 Unity 遊戲。
因此一開始先檢查遊戲邏輯,也就是:
1 | |
比對後可以發現,這份 Assembly-CSharp.dll 和上一題相同。
這個結果很重要,因為代表:
- 這題不太像是要再逆一次遊戲主邏輯
- flag 可能不在 C# 程式碼裡
- 更可能藏在共用遊戲的角色資產、動畫、材質或貼圖中
題目名稱是 Hidden in the Cloak,而題目敘述又提到「披風」,所以後續分析方向就轉向角色相關資產。
2. 尋找角色相關 bundle
在 Unity 遊戲資料夾中,可以從 StreamingAssets 開始找 asset bundle。
其中 StreamingAssets/bundles/character_main.manifest 裡可以看到這包角色資產包含以下內容:
1 | |
這裡可以看出角色是用 Spine 製作的。
Spine 角色通常會包含:
character.png:角色 atlas 圖片character.atlas.txt:定義 atlas 中每個 region 的位置character.json:定義骨架、slot、attachment、animation
因此接下來的分析重點就是把 character_main bundle 解出來,檢查這三個檔案。
3. 解出 Spine 角色資產
將 character_main bundle 裡的主要資產抽出後,可以得到:
1 | |
其中:
character.png是整張 atlas 圖character.atlas.txt定義每個小圖塊對應的 regioncharacter.json定義 bones、slots、attachments、animations
這一步的重點是,flag 如果藏在角色外觀中,通常不會直接出現在程式碼,而是會被拆成圖片 region 或 attachment,透過骨架或動畫組合出來。
4. 在 atlas 圖片中發現可疑文字
打開 character.png 後,可以發現除了正常的角色部件以外,還有很多白底黑字的小圖塊。
可疑文字片段包含:
1 | |
以及一些單一字元或數字,例如:
1 | |
這裡可以判斷,flag 不是以一整張完整圖片直接放在 atlas 裡,而是被拆成很多 sprite region。
所以接下來要做的事情,就是找出這些文字 sprite 的正確排列順序。
5. 從 character.json 找到文字 slot
繼續檢查 character.json,可以看到裡面有一組很可疑的 slot:
1 | |
它們分別掛在:
1 | |
對應的 attachment 則是一串文字 fragment:
1 | |
這一段其實已經幾乎把 flag 的順序列出來了。
因為 Spine 的 slot 會決定 attachment 顯示在骨架上的順序與對應位置,所以依照 s013 ~ s025 的順序把 attachment 名稱接起來,就能還原出完整訊息。
6. 觀察可疑動畫 emote_a
在 character.json 的 animation 區塊中,除了常見的動畫,例如:
1 | |
還有一個比較可疑的動畫:
1 | |
這個動畫不影響主要遊戲移動,但它會對 b013 ~ b025 做大幅度的平移與旋轉。
也就是說,這些文字 sprite 不是隨便塞在 atlas 裡,而是設計成:
- 平常藏在角色或披風附近,不容易看出完整內容
- 透過特定 Spine 動畫後,文字會被攤開
- 攤開後就能讀出 flag
這也呼應題目名稱 Hidden in the Cloak,flag 是藏在角色披風相關的資產與動畫中。
7. 拼出完整 flag
最後依照 slot 順序,將 s013 ~ s025 對應的文字片段接起來:
1 | |
合併後得到:
1 | |
到這裡可以確認,這題不是傳統的演算法逆向或 patch 題,而是 Unity asset / Spine 資產取證題。
出題者把 flag 拆成許多 sprite,藏在角色 atlas 中,再透過 Spine slot 和特殊動畫把它們組合成可讀訊息。
Flag
1 | |
std::print("Hello, World") revenge
Category: Pwn
題目敘述
std::print("Hello, World") revenge
1 | |
附件內容
題目提供一個 Linux ELF 執行檔,並且遠端服務位於:
1 | |
這題是 pwn 題,程式一開始會印出:
1 | |
接著會持續讀取使用者輸入。只要輸入的第一個 byte 是 y 或 Y,程式就會繼續執行;否則會直接 exit(0)。
解題過程
1. 檢查程式保護
首先使用 checksec 查看 binary 保護:
1 | |
可以看到保護狀態大致如下:
1 | |
這組保護提供了幾個重要資訊:
Full RELRO:GOT 不能直接改寫No canary:如果有 stack overflow,可以直接覆蓋 return addressNX enabled:不能直接在 stack 上執行 shellcodeNo PIE:binary 內部位址固定,ROP gadget 和函式位址可以直接硬編
因此這題看起來很適合往 stack overflow + ROP 的方向分析。
2. 找到 Question() 的 stack overflow
接著檢查程式讀取輸入的位置,可以在 Question() 中看到關鍵邏輯:
1 | |
這段程式很明顯有問題:
- stack buffer 只有
0x50bytes - 但
read()會讀入0xe0bytes
也就是說,只要輸入超過 0x50 bytes,就可以覆蓋 saved rbp 和 return address。
這就是本題的主要漏洞。
3. 發現 flag 已經被讀進全域變數
繼續往前看程式初始化流程,可以發現 main() 一開始會先呼叫 load_flag()。
在 load_flag() 中,程式會把 flag 讀進全域區域:
1 | |
因此 flag 其實已經存在 .bss 的固定位置:
1 | |
這個發現很重要,因為代表不需要開 shell,也不需要另外呼叫 open/read/write 去讀 flag 檔。
只要能把 0x427040 的內容印出來,就可以取得 flag。
4. 嘗試直接呼叫 fwrite
最直覺的做法是用 ROP 呼叫:
1 | |
在 amd64 calling convention 下,fwrite() 需要準備以下參數:
1 | |
但這題有一個麻煩點:binary 裡 gadget 不多。
雖然可以找到:
pop rdipop rsi
但缺少好用的:
pop rdxpop rcx
因此如果硬要自己組一條完整的 fwrite() ROP chain,會變得不太方便。
5. 改用程式內部原本的輸出流程
既然 binary 自己本來就會印出東西,那就不一定要自己從零呼叫 fwrite()。
這題的核心思路是重用程式內部已經存在的輸出路徑,也就是讓程式自己準備好 fwrite 需要的參數,然後把原本要輸出的資料換成 flag buffer。
整體概念如下:
1 | |
最後利用的方向是:
- 第一次 overflow 改掉
rbp和 return address - 重新跳回
Question()+8,讓下一段 payload 被讀到.bss - 在
.bss上布置假的資料結構 - 進入 binary 內部
std::vprint_nonunicode/fwrite附近的輸出流程 - 讓程式把
0x427040的 flag buffer 當成要輸出的資料印到stdout
這樣就不用自己湊齊 rdx、rcx 等參數。
6. 分成三個 stage 的原因
雖然有 stack overflow,但單次 read() 最多只讀 0xe0 bytes,payload 空間不算特別大。
因此 exploit 被拆成三段。
Stage 1:pivot 到 .bss
第一段 payload 只做一件事:
- 把 saved
rbp改到.bss - return 到
Question()+8
Question()+8 對應的位置是:
1 | |
這樣下一輪 read() 就不會把資料讀回原本 stack,而是會讀到我們控制的 .bss 區域。
Stage 2:布置 flag pointer 和長度
第二段 payload 會在 .bss 上放好假的資料結構,例如:
1 | |
接著再次跳回 Question()+8,讓程式繼續讀入第三段 payload。
Stage 3:接上內部 fwrite 路徑
第三段 payload 負責最後收尾:
- 取出 copy-relocated 的
stdout指標 - 把
stdout寫到 fake frame 對應位置 - 跳進 binary 內部準備好
fwrite參數的那段邏輯
最後程式等價於執行:
1 | |
程式後面就算 crash 也沒關係,因為 flag 已經先被送出來了。
7. 整理關鍵位址
利用過程中比較重要的位址如下:
1 | |
這些位址可以直接使用的原因是 binary 是 No PIE,所以程式本身的位址在本機與遠端都固定。
8. 撰寫 exploit
最後整理出 exploit script:
1 | |
9. 執行 exploit 取得 flag
執行 exploit:
1 | |
可以取得輸出:
1 | |
到這裡就成功利用 stack overflow、stack pivot 和程式內部輸出流程,把已經載入 .bss 的 flag 印出來。
Flag
1 | |
EasyZKP
Category: Crypto
題目敘述
You are neither proover or verifier this time.
Author: whale120
nc chals1.ais3.org 48765
EasyZKP
Category: Crypto
題目敘述
You are neither proover or verifier this time.
nc chals1.ais3.org 48765
檔案都在資料夾裡了
Author: whale120
附件內容
題目提供了一包壓縮檔,內含 Python 原始碼,主要分為兩個部分:
verifier/chal.py:負責處理與使用者的互動與 Challenge 邏輯。shared/zkp.py:負責處理 SHA-256 Digest 的 Hash 計算與 Proof 生成邏輯。
這是一個類似零知識證明 (Zero-Knowledge Proof) 的協議,伺服器允許我們透過 Oracle 來測試自訂參數,最終目標是通過連續 16 回合的 Challenge 才能獲得 Flag。
解題過程
1. 觀察 Proof 計算與模冪漏洞
閱讀 zkp.py 中的 compute_proof_from_digest 函數,可以發現這段程式碼會將 256-bit 的 SHA-256 Digest 逐 bit 處理:
- 若 bit 為
1:執行value = pow(value, seed, N) - 若 bit 為
0:執行value = (value + seed) % N
同時,題目給定了模數 $N = 1371086445846712667727718527036585861739497962228620061686456237722902428356146756731186939$。
因為這是一個經典的 RSA $N = p \times q$,且我們可以直接透過線上工具或演算法將其因式分解,找出 $p, q$。
知道 $p, q$ 後,我們可以計算出卡邁克爾函數 (Carmichael function):
1 | |
根據尤拉定理,只要 $v$ 與 $N$ 互質,$v^L \equiv 1 \pmod N$。
這代表如果我們在呼叫 Oracle 時,故意將 seed 注入為 $L$,那麼每次遇到 1-bit,數值都會被重置為 1;而遇到 0-bit 則是單純加上 $L$。這導致了最終的 Proof 值只會保留「最後一個 1-bit 後方連續的 0-bit 數量」!
公式為:$P \equiv 1 + Z_{last} \cdot L \pmod N$
2. 透過 Oracle 提取完整 256-bit Digest
知道漏洞後,我們可以計算出最後一個 1-bit 的精確位置 $k = 255 - Z_{last}$。
由於伺服器的 Oracle 提供了 f (bit flip) 參數,允許我們翻轉 Digest 中特定的 bit:
- 我們先送出一次請求得到 Proof,計算出最後一個
1-bit 的位置。 - 利用 bit flip 將該位置的
1翻轉成0。 - 重複以上步驟,我們就能一路往前推算,得到前一個
1-bit 的位置。
因為 Oracle 允許最多 128 次查詢,而一串 256-bit 的 Hash 平均大約只有 128 個 1-bit,我們只要寫個迴圈一直翻轉,就能精準還原出整個 256-bit 的 SHA-256 內部狀態。
我們撰寫 extract_full.py 來自動化這個過程,提取出 sha256(FLAG + user_part_oracle + server_part_oracle) 的結果:
1 | |
3. 利用長度擴展攻擊 (LEA) 繞過驗證
接下來要通過 Challenge,我們需要給出正確的 Proof。
Challenge 會隨機產生一段新的 server_part_challenge,並要求我們計算:sha256(FLAG + decode_suffix(shown_nonce) + server_part_challenge)
我們將提交的 shown_nonce 精心構造為 user_part_oracle + server_part_oracle + padding,這樣 Challenge 計算的 Hash 就會變成:sha256(FLAG + user_part_oracle + server_part_oracle + padding + server_part_challenge)
這正是經典的長度擴展攻擊 (Length Extension Attack, LEA)!
前方的區塊剛好等於我們剛剛從 Oracle 提取出來的 Hash。我們實作了一個純 Python 的 SHA-256 擴展腳本 lea.py,以剛剛的 256-bit 狀態為基底,繼續 Hash 剩下的 server_part_challenge:
1 | |
4. 爆破 FLAG 長度並獲取 Flag
長度擴展攻擊需要知道原始訊息的精確長度來計算初次的 padding。
因為我們並不知道 FLAG 的實際長度,但 Challenge 如果驗證失敗,會在第一回合就斷線。
因此,我們撰寫了 solve_len.py,自動從長度 5 開始往上窮舉,只要連線沒斷就代表長度猜對了:
1 | |
執行這個腳本後,當長度猜測到 62 時,伺服器沒有斷線,我們順利通過了連續 16 回合的 Challenge 驗證,成功拿到 Flag。
Flag
1 | |