2025 AIS3 pre-exam & My First CTF official writeup
Author: 堇姬Naup
前言
這次在 AIS3 出了 4 題,分別是 1 web、1 misc、2 pwn
分別是
- Tomorin db 🐧 (web, 282/473 solve)
- ♖ PyWars ♖ (Misc, 1/473 solve)
- MyGO schedule manager α (Pwn, 12/473 solve)
- MyGO schedule manager β (Pwn, 0/473 solve)
source code :
https://github.com/Naupjjin/2025-AIS3-pre-exam-challenge
以下是這四題的官方題解
Tomorin db 🐧
把題目 source code 打開可以發現非常短
1 | package main |
他建立了一個 File server
我們可以通過這個網站來去訪問 /app/Tomorin
底下的東西
去看這個目錄底下的檔案,除了三張圖片外,還有 flag
但是當我們嘗試去訪問 flag 時,會被第二個 route 阻擋,而被 Redirect
一開始想法可能會有像是 http://host/./flag
、http//host/../flag
但是嘗試幾次後都會發現沒辦法拿到 flag
這時候應該會產生疑問,明明用 ./flag
,為啥會訪問到 /flag
這個 route
這邊來看 source code
首先,通過 Dockerfile 知道版本是 1.23.1 所以看這份
當你發出 request 後,會進到 func Hnadler
之後他會去 call findHnadler
src/net/http/server.go#L2573C1-L2579C2
1 | func (mux *ServeMux) Handler(r *Request) (h Handler, pattern string) { |
call 進去後,這邊就可以分成兩種狀況了
Request method 是 CONNECT 跟不是 CONNECT
如果不是 CONNECT 會進入到以下分支,這裡會去做 clean_path,把你輸入的 path 做正規化
這也就是為啥你輸入像是上述的,會導到 /flag
的原因
cleanpath docs
cleanpath source code
1 | } else { |
所以當我們用 CONNECT 請求就不會發生問題
因為 CONNECT 沒有去做 clean_path
1 | if r.Method == "CONNECT" { |
如果不想看 source code 也沒關係,因為這件事實質上有寫在官方文檔
docs

- uintended : 這邊出現了一個 uintended 讓這題變得很簡單,就是用
http://host/.%2fflag
,把/
做 urlencode 就不會被 clean path 掉了
exploit
1 | import socket |
Flag : AIS3{G01ang_H2v3_a_c0O1_way!!!_Us3ing_C0NN3ct_M3Th07_L0l@T0m0r1n_1s_cute_D0_yo7_L0ve_t0MoRIN?}
PyWars
1 | from flask import Flask, render_template, request, redirect, url_for |
一樣有給 source code 所以來分析
他是一個檔案上傳的 website
根據 source code 可以看到他會把你輸入的 source code 前面加上 exit()\n
包起來拿去執行
所以這題的目標是要繞過前面加入的 exit(0)
並拿到 python 任意執行
另外這題還會去 check 你的檔案中是否存在 TomorinIsCuteAndILovePython
以及去替換掉你檔案中的特殊字元成空白
首先先看如何繞過 exit()
去看 python 官方文檔可以發現
https://docs.python.org/3.14/library/zipapp.html
有一個檔案叫做 pyz,其實就是 zip
一個 python 檔案,如果包成 pyz 仍舊可以被 python3 <.pyz>
的方法來執行
因為它是一個 zip 所以前面會存在一些 magic number 等 header
1 | 00000000: 504b 0304 1400 0000 0000 9753 7f5a dc1b PK.........S.Z.. |
我們在這前面加上一些無關的 bytes 仍舊可以正常執行他
所以說 exit() 會被加到 header 前面
這樣就可以通過上傳 .pyz
來繞過他了
另外無論你的副檔名是啥只要結構是 zip 就可以正常去執行,所以副檔名改為 .pyz.py
就可以上傳他了
把 script 寫在 script/__main__.py
裡面,並包起來就可以了
最後過濾的字元沒有過濾裝飾器,所以通過裝飾器就可以繞過他的字元過濾了
因為前端不回顯,所以就寫個 request 把 flag 送出來
PS: 另外這題有蠻多套件沒有安裝的,不過應該還是可以找的到 urllib.request
這個可以用來執行 request
另外這題其實有一些線索可以找到 .pyz
就是前端有寫一個允許上傳的有 .pyo、.pyc
順著去找 python 還有哪些神奇的檔案格式就可以找到 .pyz 了

exploit
run.sh
1 | python3 -m zipapp script |
script/__main__.py
1 | from os import popen |
Flag : AIS3{Success!!!!!_y0u_are_M6s73r_0f_python@z1p_1s_c00l…and_dec0rat0r_1s_c00l!}
MyGO schedule manager α
一樣有給 source code 先看 code
1 |
|
一開始會要求你輸入帳號密碼
寫死的就直接輸入就好了
之後他會提供你四種功能,供你去操作 schedule
schedule 是個 struct,分成第一部份的 title 跟第二步份的 content
一個是有限大小的 title (0x16)
另一個是 std string
漏洞點很明顯,create 跟 edit 都直接用 cin 輸入一個東西
cin 沒有限制長度所以可以 overflow
我們知道 std string 是一個長這樣的 struct
offset | description |
---|---|
0x0 | data’s pointer |
0x8 | vector size |
0x10 | vector capacity |
0x18 | unused padding or alignment space |
他會根據 0x0 的 pointer 去往那上面寫入東西(通常這塊是個 heap,需要擴容就重新 malloc,並指向新的 chunk)
總之因為有 overflow 可以改 pointer
這樣就可以製造一個任意讀、任意寫了
通過這個任意讀可以 leak libc base
因為開 FULL RELRO 的關係,沒辦法改寫 GOT (chal 本身的 GOT) 成 backdoor
所以改寫 libc 上的 GOT (libc 開 partial RELRO),跳到 backdoor 上拿 shell
- IO_FILE 去開 shell
- 讀 environ 去 leak stack address,來去寫 return address
等都是預期可行的解法,這題拿到任意讀寫後就有很多解法了
exploit
1 | from pwn import * |
Flag : AIS3{MyGO!!!!!T0m0rin_1s_cut3@u_a2r_mAsr3r_0f_CP1usp1us_string_a2d_0verf10w!_alpha_v3r2on_have_br0ken…Go_p1ay_b3ta!}
MyGO schedule manager β
analyze source code
這題沒給 source code,需要逆向,不過我寫 writeup 就直接看 source code
首先這題如果觀察 run.sh 會發現啟動時候用 LD_PRELOAD 去用 lib 底下的 libc
1 |
|
這是一個 2.39 的 libc (Ubuntu GLIBC 2.39-0ubuntu8.3)
如果去檢查一下,會發現這個 libc 好像跟原版的 libc 不一樣
這邊用 bindiff 檢查哪裡不一樣後可以發現,只有一個 function 相似度是 0.99

唯一不一樣的地方是 jz 被 patch 成了 jnz

這個被 patch 的 function 應該蠻眼熟的,就是 house of cat 會用到的地方,到這邊我們先到這裡打住,之後會用到
接下來開始分析 source code 吧
1 | int main() { |
他去創建了一個 schedule manager 的物件
之後 call run
construct 時候會做兩件事,一個是初始化 stdin stdout 等
之後去 call login
1 | ScheduleManager() { |
跟上一題一樣這個 login 寫死的,直接登進去就好了
1 | void login() { |
登入進去後他提供你四個功能
- add
- edit
- delete
- show
並且限制操作次數在 12 次內
1 | void run() { |
這四個功能非常明顯,就不多贅述
1 | void add_schedule() { |
前置知識 std::string & std::vector
std::vector
offset | description |
---|---|
0x0 | 第一個元素的 pointer |
0x8 | vector 結尾的 pointer |
0x10 | array capacity 上限的 pointer |
std::string
offset | description |
---|---|
0x0 | 資料所在位置 pointer |
0x8 | vector size |
0x10 | vector capacity |
0x18 | 存資料或單純 padding |
另外我們知道 vector 可以一直往裡面塞 elemet
當我們初始化一個 vector,他會根據二的冪次來擴容 (capacity)
$2^n, n \in { 0, 1, 2, 3, \dots }$
舉個例子,當現在已經存了兩個元素,當再 push 一個元素進去後,他會觸發擴容
把原本的 capacity 從 2 擴成 4
他會把原本存的 vector 那塊 chunk free 掉重新 malloc 一塊新的回來用
analyze bug
先建立對於 std::vector<std::string> schedule;
長相的認知
stack 上會有一個 vector 結構體,上面有 pointer 指向 vector 存 element 的 chunk,這個 chunk 上會有 std::string 的結構體
每個 std::string 又會對應一塊 chunk 存 data
看完 source code 後,應該可以發現一件事
他判斷 vector 的大小居然不是使用 .size
而是使用 (int)schedule.capacity()
我們知道 size 是一個 vector 當前有的元素數量
而 capacity 是一個 vector 當前容量,也就是可以存多少個
所以這邊就有一個 out of boundary 的問題
首先我們先輸入一個 0x500 大小的 chunk 跟一個 0x10 的 chunk (這邊有個認知,把輸入的長度當作是 malloc chunk size 來看)
一個用來進到 unsorted bin 用來 leaklibc 一個用來防止被合併到 top chunk
之後去 delete 掉第二塊,再去 delete 第一塊
通過 show 來去把 libc leak 出來
erase 不會去把原來的 pointer 清掉,所以也有 UAF 的問題
1 | iterator erase(iterator position){ |
接下來我們用 add 送出一塊這個,並把他 free 掉
這是一個 vector 中 std::string element = 4
我們把最後一項 (第四項) 改成了 IO stderr 的 File structure (這裡可以改成其他 libc address,這在之後就是你想要任意寫的地方)
1 | payload1 = p64(0xaabbccdd) + p64(0) + p64(0) + p64(0) |
之後去 add 兩次
當我們 add 第三次時候,會去觸發擴容,他 malloc 時候會剛好把這塊 malloc 回來
前三個元素會被搬過來寫掉,但是最後一個元素並不會被覆蓋,殘留在了 vector chunk 上
又剛好現在 capacity = 4,所以可以 access 到這塊
我們就拿到了一個 libc 任意寫
既然是 glibc 2.39,要打 FSOP 就可以用 house of cat 了 (把要偽造的都寫在 stderr 本身內就可以了)
house of cat 簡單來說就是雖然有針對 vtable 的 pointer 去做 check 了
但他仍舊只是 check 一定的範圍內而已,我們可以把原有的 vtable 改動,來讓最後去 flush 時候 call 到預期之外的函數
他是用 IO_WFILE_JUMPS
上的 _IO_wfile_seekoff
這裡需要堆一些條件,這邊自行看 code
之後去 call _IO_switch_to_wget_mode
這塊的 asm 長這樣
1 | 0x76063da71fa0 <__GI__IO_switch_to_wget_mode>: endbr64 |
這塊其實做了不少事情,首先 rdi 是傳進來的 IO_FILE struct address
- 將
[rdi+0xa0]
值給 rax (簡稱為 rax1) - 將
[rax1+0x20]
值給 rdx - 將
[rax1+0xe0]
值給 rax (簡稱為 rax2) - 之後去 call
[rax2+0x18]
看到這裡其實 RCE 的雛型已經出來了
如果我們將 [rax2+0x18]
設為 system address
fp 的開頭設為 /bin/sh
就可以 call 到 system("/bin/sh")
至於這些值是甚麼就交給各位自行看 code 了
圖示
https://docs.google.com/presentation/d/1PzmbYHiOAH23bij5KFjEc6LPUYO9WVhSm8--6QlusDc/edit?usp=sharing
exploit
最後直接上 exploit
1 | from pwn import * |
Flag : AIS3{MyGO!!!!!_is_A_G0od_band_6nd@u_a2r_Mas7er_of_cplusplus_he6p!_beta_v3r2on_have_br0ken!!!_T0m0r1n_1s_cut3_and_hous3_0f_cat_1s_als0_cut3!!!}
all hint
btw,這題釋出超多 hint 的,這邊是所有 hint
- hint 1 : 我昨晚在睡覺的時候,樂奈不小心按到鍵盤把我的 github repo 公開了QQ
https://github.com/Naupjjin/MyGO-schedule-manager-beta-Hint - hint 2 : There seems to be a new issue with the software. It appears to be a bug, but why?
Don’t tell me Rana accidentally pressed something again while coding?(X
https://github.com/Naupjjin/MyGO-schedule-manager-beta-Hint/issues/2 - hint 3 : New issue on my github repo. It has some cool knowledge and question.
https://github.com/Naupjjin/MyGO-schedule-manager-beta-Hint/issues/4
https://github.com/Naupjjin/MyGO-schedule-manager-beta-Hint/issues/5 - hint 4 : 他為啥要叫 beta 就代表跟 alpha 有點關係吧
- hint 5 :
- GDB is your good friend . Use GDB to dynamically analyze the relationships between each chunk.
- Bindiff can use to analyze libc patch
- hint 6 :
- 想一下 std::vector<std::string> 在 heap 上到底長怎麼樣,建議畫圖出來,
- exploit 的時候一定會缺甚麼,請回去看先前的 hint,缺甚麼就自己造
- hint 7 : Cat is cute. Rana is cute. 🐱 is also cute. Can you help them build a house?
- hint 8 :
- 殘留、殘留、殘留,很重要所以說三次,沒有殘留就自己做出殘留
- Hint 3 的問題 (Why can I keep adding elements to a std::vector? ) 請搭配這個 Hint 思考
- Hint 2 的 bug (https://github.com/Naupjjin/MyGO-schedule-manager-beta-Hint/issues/2) 請去思考為甚麼,並且思考你還能做啥
- Hint 7 可以幫你推測現在的第一個目標是甚麼 (不知道的人可以抓關鍵字去 google)
- 剩下的 hint 可以幫你排除可能並找到 get shell 的路徑
- 這題因為操作次數的關係,慣性打 heap 的部分手法不管用,請跳脫框架,這題用的是 C++
- hint 9 : hint 6 + hint 8 = 想 access 甚麼就想辦法讓甚麼東西殘留
- hint 10 : 都拿到任意寫了,相信各位可以解出來的,想辦法拿 shell 吧
exit() --> __run_exit_handlers --> _IO_cleanup --> _IO_flush_all --> _IO_wfile_seekoff --> _IO_switch_to_wget_mode
- hint 11 : 稍微改一下模板就可以拿 shell 了,想想看怎麼 one shot libc 任意寫 house of cat
after all
貓貓可愛可愛可愛
