TJCTF 2025¶
Đề và code giải mình để ở đây.
alchemist-recipe¶
Đề bài¶
import hashlib
SNEEZE_FORK = "AurumPotabileEtChymicumSecretum"
WUMBLE_BAG = 8
def glorbulate_sprockets_for_bamboozle(blorbo):
zing = {}
yarp = hashlib.sha256(blorbo.encode()).digest()
zing['flibber'] = list(yarp[:WUMBLE_BAG])
zing['twizzle'] = list(yarp[WUMBLE_BAG:WUMBLE_BAG+16])
glimbo = list(yarp[WUMBLE_BAG+16:])
snorb = list(range(256))
sploop = 0
for _ in range(256):
for z in glimbo:
wob = (sploop + z) % 256
snorb[sploop], snorb[wob] = snorb[wob], snorb[sploop]
sploop = (sploop + 1) % 256
zing['drizzle'] = snorb
return zing
def scrungle_crank(dingus, sprockets):
if len(dingus) != WUMBLE_BAG:
raise ValueError(f"Must be {WUMBLE_BAG} wumps for crankshaft.")
zonked = bytes([sprockets['drizzle'][x] for x in dingus])
quix = sprockets['twizzle']
splatted = bytes([zonked[i] ^ quix[i % len(quix)] for i in range(WUMBLE_BAG)])
wiggle = sprockets['flibber']
waggly = sorted([(wiggle[i], i) for i in range(WUMBLE_BAG)])
zort = [oof for _, oof in waggly]
plunk = [0] * WUMBLE_BAG
for y in range(WUMBLE_BAG):
x = zort[y]
plunk[y] = splatted[x]
return bytes(plunk)
def snizzle_bytegum(bubbles, jellybean):
fuzz = WUMBLE_BAG - (len(bubbles) % WUMBLE_BAG)
if fuzz == 0:
fuzz = WUMBLE_BAG
bubbles += bytes([fuzz] * fuzz)
glomp = b""
for b in range(0, len(bubbles), WUMBLE_BAG):
splinter = bubbles[b:b+WUMBLE_BAG]
zap = scrungle_crank(splinter, jellybean)
glomp += zap
return glomp
def main():
try:
with open("flag.txt", "rb") as f:
flag_content = f.read().strip()
except FileNotFoundError:
print("Error: flag.txt not found. Create it with the flag content.")
return
if not flag_content:
print("Error: flag.txt is empty.")
return
print(f"Original Recipe (for generation only): {flag_content.decode(errors='ignore')}")
jellybean = glorbulate_sprockets_for_bamboozle(SNEEZE_FORK)
encrypted_recipe = snizzle_bytegum(flag_content, jellybean)
with open("encrypted.txt", "w") as f_out:
f_out.write(encrypted_recipe.hex())
print(f"\nEncrypted recipe written to encrypted.txt:")
print(encrypted_recipe.hex())
if __name__ == "__main__":
main()
Giải¶
Đây là một bài obfuscate code khiến chúng ta không hiểu được ý nghĩa của từng hàm, từng biến.
Đầu tiên hãy bắt đầu từ hàm main.
def main():
try:
with open("flag.txt", "rb") as f:
flag_content = f.read().strip()
except FileNotFoundError:
print("Error: flag.txt not found. Create it with the flag content.")
return
if not flag_content:
print("Error: flag.txt is empty.")
return
print(f"Original Recipe (for generation only): {flag_content.decode(errors='ignore')}")
jellybean = glorbulate_sprockets_for_bamboozle(SNEEZE_FORK)
encrypted_recipe = snizzle_bytegum(flag_content, jellybean)
with open("encrypted.txt", "w") as f_out:
f_out.write(encrypted_recipe.hex())
print(f"\nEncrypted recipe written to encrypted.txt:")
print(encrypted_recipe.hex())
if __name__ == "__main__":
main()
Ở dòng tính encrypted_recipe, đầu vào là flag_content nên chúng ta có thể đoán được snizzle_bytegum là hàm encrypt và jellybean khóa vì hàm encrypt luôn có đầu vào là bản rõ và khóa.
Khi đó mình dùng tính năng Rename của IDE để thay jellybean thành key, và thay snizzle_bytegum thành encrypt.
Trước đó, jellybean được tính bởi hàm glorbulate_sprockets_for_bamboozle nên có thể đoán rằng đây là hàm sinh khóa và mình cũng đổi tên thành keygen.
Như vậy hàm main của mình có dạng
def main():
try:
with open("flag.txt", "rb") as f:
flag_content = f.read().strip()
except FileNotFoundError:
print("Error: flag.txt not found. Create it with the flag content.")
return
if not flag_content:
print("Error: flag.txt is empty.")
return
print(f"Original Recipe (for generation only): {flag_content.decode(errors='ignore')}")
key = keygen(SNEEZE_FORK)
encrypted_recipe = encrypt(flag_content, key)
with open("encrypted.txt", "w") as f_out:
f_out.write(encrypted_recipe.hex())
print(f"\nEncrypted recipe written to encrypted.txt:")
print(encrypted_recipe.hex())
Tiếp theo chúng ta xét hàm keygen hoặc encrypt.
Hàm keygen có dạng
def keygen(blorbo):
zing = {}
yarp = hashlib.sha256(blorbo.encode()).digest()
zing['flibber'] = list(yarp[:WUMBLE_BAG])
zing['twizzle'] = list(yarp[WUMBLE_BAG:WUMBLE_BAG+16])
glimbo = list(yarp[WUMBLE_BAG+16:])
snorb = list(range(256))
sploop = 0
for _ in range(256):
for z in glimbo:
wob = (sploop + z) % 256
snorb[sploop], snorb[wob] = snorb[wob], snorb[sploop]
sploop = (sploop + 1) % 256
zing['drizzle'] = snorb
return zing
Thông thường đầu vào của hàm sinh khóa là một seed nào đó nên mình sẽ thay blorbo thành seed.
Hàm keygen trả về biến zing nên có thể thay zing thành key, chính là khóa mã hóa.
Biến WUMBLE_BAG có độ dài là \(8\) và
cũng được sử dụng trong hàm encrypt nên đó
chính là BLOCK_SIZE. Ngoài ra chúng ta không
cần xem xét quá nhiều bên trong hàm keygen,
chỉ cần biết rằng khóa có ba phần, phần đầu key['flibber']
có \(8\) bytes, phần hai key['twizzle']
có \(8\) bytes, và phần cuối key['drizzle']
có \(256\) bytes. Do seed cố định nên key sinh ra cũng cố định. Để dễ nhìn hơn thì mình cũng đổi tên các biến khác nhưng các bạn không làm cũng không sao. Khi đó hàm keygen có dạng
def keygen(seed):
key = {}
key_hash = hashlib.sha256(seed.encode()).digest()
key['flibber'] = list(key_hash[:BLOCK_SIZE])
key['twizzle'] = list(key_hash[BLOCK_SIZE:BLOCK_SIZE+16])
glimbo = list(key_hash[BLOCK_SIZE+16:])
S = list(range(256))
i = 0
for _ in range(256):
for z in glimbo:
j = (i + z) % 256
S[i], S[j] = S[j], S[i]
i = (i + 1) % 256
key['drizzle'] = S
return key
Tiếp theo, xét hàm encrypt.
def encrypt(bubbles, jellybean):
fuzz = BLOCK_SIZE - (len(bubbles) % BLOCK_SIZE)
if fuzz == 0:
fuzz = BLOCK_SIZE
bubbles += bytes([fuzz] * fuzz)
glomp = b""
for b in range(0, len(bubbles), BLOCK_SIZE):
splinter = bubbles[b:b+BLOCK_SIZE]
zap = scrungle_crank(splinter, jellybean)
glomp += zap
return glomp
Như đã phân tích ở hàm main, tham số đầu của encrypt là bản rõ và tham số thứ hai là khóa. Như vậy mình đổi tên bubbles thành plaintext và jellybean thành key.
Chúng ta có thể đoán rằng fuzz là padding (PKCS7) vì plaintext được thêm vào các bytes cho đủ độ dài BLOCK_SIZE (trước là WUMBLE_BAG).
Sau đó splinter là các khối bản rõ (độ dài \(8\)) nên có thể đoán rằng scrungle_crank là hàm mã hóa từng khối, mình đổi tên thành encrypt_block.
Như vậy hàm encrypt của mình có dạng
def encrypt(plaintext, key):
pad = BLOCK_SIZE - (len(plaintext) % BLOCK_SIZE)
if pad == 0:
pad = BLOCK_SIZE
plaintext += bytes([pad] * pad)
ciphertext = b""
for b in range(0, len(plaintext), BLOCK_SIZE):
block = plaintext[b:b+BLOCK_SIZE]
zap = encrypt_block(block, key)
ciphertext += zap
return ciphertext
Cuối cùng, xét hàm encrypt_block.
def encrypt_block(dingus, sprockets):
if len(dingus) != BLOCK_SIZE:
raise ValueError(f"Must be {BLOCK_SIZE} wumps for crankshaft.")
zonked = bytes([sprockets['drizzle'][x] for x in dingus])
quix = sprockets['twizzle']
splatted = bytes([zonked[i] ^ quix[i % len(quix)] for i in range(BLOCK_SIZE)])
wiggle = sprockets['flibber']
waggly = sorted([(wiggle[i], i) for i in range(BLOCK_SIZE)])
zort = [oof for _, oof in waggly]
plunk = [0] * BLOCK_SIZE
for y in range(BLOCK_SIZE):
x = zort[y]
plunk[y] = splatted[x]
return bytes(plunk)
Tham số thứ nhất của encrypt_block chính là bản rõ nên mình đổi tên dingus thành plaintext, tương tự tham số thứ hai là khóa nên mình đổi sprockets thành key.
Như mình đã nói ở trên, key gồm ba phần.
Phần cuối
key['drizzle']của khóa có độ dài \(256\) bytes nên có thể thấyzonkedlà S-box, mình đổi tên thànhSbox_key_third.Phần thứ hai
key['twizzle']của khóa có \(8\) bytes nên mình đổi tênquixthànhkey_second.Tiếp theo,
splattedlà XOR của phần cuối và phần thứ hai nên mình đổi tên thànhthird_X_second.Phần đầu
key['flibber']có \(8\) bytes nên mình đổi tênwigglethànhkey_first.Tiếp theo,
wagglylà sắp xếp lại giá trị củakey_firstnên mình đổi tên thànhkey_first_srt.Mình bỏ qua
zortNhìn xuống một chút mình thấy hàm
encrypt_blockreturnbytes(plunk)nên có thể đoánplunklà bản mã, mình đổi tên thànhct.
Như vậy hàm encrypt_block có dạng
def encrypt_block(block, key):
if len(block) != BLOCK_SIZE:
raise ValueError(f"Must be {BLOCK_SIZE} wumps for crankshaft.")
Sbox_key_third = bytes([key['drizzle'][x] for x in block])
key_second = key['twizzle']
third_X_second = bytes([Sbox_key_third[i] ^ key_second[i % len(key_second)] for i in range(BLOCK_SIZE)])
key_first = key['flibber']
key_first_srt = sorted([(key_first[i], i) for i in range(BLOCK_SIZE)])
zort = [oof for _, oof in key_first_srt]
ct = [0] * BLOCK_SIZE
for y in range(BLOCK_SIZE):
x = zort[y]
ct[y] = third_X_second[x]
return bytes(ct)
Khi đã viết lại tên biến thì chúng ta thực hiện ngược lại là giải mã được.
bacon-bits¶
Đề bài¶
with open('flag.txt') as f: flag = f.read().strip()
with open('text.txt') as t: text = t.read().strip()
baconian = {
'a': '00000', 'b': '00001',
'c': '00010', 'd': '00011',
'e': '00100', 'f': '00101',
'g': '00110', 'h': '00111',
'i': '01000', 'j': '01000',
'k': '01001', 'l': '01010',
'm': '01011', 'n': '01100',
'o': '01101', 'p': '01110',
'q': '01111', 'r': '10000',
's': '10001', 't': '10010',
'u': '10011', 'v': '10011',
'w': '10100', 'x': '10101',
'y': '10110', 'z': '10111'}
text = [*text]
ciphertext = ""
for i,l in enumerate(flag):
if not l.isalpha(): continue
change = baconian[l]
ciphertext += "".join([ts for ix, lt in enumerate(text[i*5:(i+1)*5]) if int(change[ix]) and (ts:=lt.upper()) or (ts:=lt.lower())]) #python lazy boolean evaluation + walrus operator
with open('out.txt', 'w') as e:
e.write(''.join([chr(ord(i)-13) for i in ciphertext]))
Giải¶
Đề bài chuyển các kí tự của flag thành dãy \(5\) bit. Xét dãy bit song song với chuỗi text, nếu là bit \(1\) thì kí tự ở vị trí tương ứng của text là chữ hoa, ngược lại là chữ thường.
Một điều cần lưu ý là kí tự i và j đều được chuyển thành chuỗi bit \(01000\), tương tự, kí tự u và v đều được chuyển thành cùng chuỗi bit \(10011\). Do đó khi tìm ngược lại plaintext cần xét cả hai trường hợp.
close-secrets¶
Đề bài¶
Đề bài sử dụng Diffie-Hellman với các số nguyên tố \(p\) và \(g\). Chọn \(a \in [p - 10, p]\) và \(b \in [g - 10, g]\).
Đề bài tính \(u = g^a \bmod p\) và \(v = g^b \bmod p\) để trao đổi khóa. Khóa chung là \(u^b \equiv v^a \bmod p\).
Khi đó xor_key_str là SHA256 của khóa chung và được dùng trong dynamic_xor_encrypt, cụ thể là xor từng bytes của flag với xor_key_bytes.
Tiếp theo, hàm encrypt_outer lấy ASCII của từng kí tự, cộng với key_offset và nhân với key. Do key cố định (khóa chung) nên key_offset cũng cố định.
Giải¶
Chúng ta chỉ cần bruteforce \(a\) và \(b\) rồi làm ngược lại hai hàm trên.
dotdotdotv2¶
Đề bài¶
Đề bài chuyển một chuỗi dài ơi là dài thành dãy bit (mỗi kí tự tương ứng \(8\) bits) và lập ma trận có \(64\) cột, còn số hàng tùy thuộc độ dài cuối cùng. Ta gọi ma trận này là \(\mathsf{flag}\).
Đề bài sinh khóa là ma trận \(64 \times 64\) với các phần tử thuộc đoạn \([0, 0xdeadbeef]\). Ta gọi ma trận này là \(\mathsf{key}\).
Như vậy kết quả là phép nhân ma trận \(\mathsf{res} = \mathsf{flag} \cdot \mathsf{key}\).
Giải¶
Độ dài của filler là \(500\) bytes, tương ứng \(4000\) bits. Ma trận \(64 \times 64\) sẽ cần \(4096\) bits. Chúng ta biết format flag là tjctf{ nên thêm vào \(4\) bytes nữa là đủ để có ma trận \(63 \times 64\).
Chúng ta sẽ chuyển tất cả tính toán sang \(\mathbb{F}_2\) thay vì để nguyên các số lớn kia.
Khi đó ta gọi ma trận \(63 \times 64\) là \(\mathsf{ff}\), và dùng \(63\) dòng đầu của ma trận kết quả \(\mathsf{res}\) ta sẽ tìm được ma trận \(\mathsf{key}\).
Ở đây \(\mathsf{ff}\) có hạng là \(55\) nên ma trận \(\mathsf{key}\) tìm được không phải duy nhất, tệ hơn nữa là \(\mathsf{key}\) không khả nghịch. Do đó chúng ta sẽ dùng giả nghịch đảo (pseudo inverse) của \(\mathsf{key}\) để tính flag.
from sage.all import *
import itertools
n = 64
filler = "..."
flag = "tjctf{"
flag = filler + flag
with open("encoded.txt") as f:
lines = [line.strip() for line in f.readlines()]
ff = flag[:504]
ff = "".join(["".join(bin(ord(i))[2:].zfill(8)) for i in ff])
ff = matrix(GF(2), [list(map(int,list(ff[i:i+n]))) for i in range(0, len(ff), n)])
res = list(list(map(int, line.split())) for line in lines)
res = matrix(GF(2), [list(map(int, line.split(' '))) for line in lines])
sol = ff.solve_right(res[:63, :])
rr = res * sol.pseudoinverse()
result = b''
for r in rr:
row = ''.join(map(str, r))
result += bytes([int(row[i:i+8], 2) for i in range(0, 64, 8)])
print(result)
Kết quả khá bất ngờ, mình nhận được chuỗi b'In cybeRsecuritY. Như vậy mình chỉ cần sửa kí tự R thành r nữa là xong. Để ý rằng nếu xét \(8\) kí tự, tương ứng \(64\) bit, thì chỉ cần cộng \(1\) vào bit thứ \(58\).
double-trouble¶
Đề bài¶
Mã hóa AES hai lần, mỗi lần mã hóa với nửa khóa cố định và nửa khóa còn lại là một trong \(4^8 = 65536\) trường hợp.
Giải¶
Mình sử dụng OpenMP và tối ưu -O2 để bruteforce khóa tương ứng.
pseudo-secure¶
Đề bài¶
Sinh số ngẫu nhiên.
Giải¶
Sử dụng module extend_mt19937_predictor ở ExtendMT19937Predictor.
seeds¶
Đề bài¶
Sử dụng seed để sinh ra khóa cho thuật toán AES.
Giải¶
Seed "có vẻ" cố định và luôn cho ra cùng khóa, bất kể thời gian.
theartofwar¶
Đề bài¶
Đề bài sinh \(e\) modulus \(n_i\) và mã hóa cùng bản rõ \(m\) với \(c_i = m^e \bmod n_i\).
Giải¶
Sử dụng định lí số dư Trung Hoa (broadcast attack) và tính căn bậc \(e\) để tìm lại \(m\).
Writeup tới đây là hết. Cám ơn các bạn đã đọc.