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']\(8\) bytes, phần hai key['twizzle']\(8\) bytes, và phần cuối key['drizzle']\(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 plaintextjellybean 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.

  1. Phần cuối key['drizzle'] của khóa có độ dài \(256\) bytes nên có thể thấy zonked là S-box, mình đổi tên thành Sbox_key_third.

  2. Phần thứ hai key['twizzle'] của khóa có \(8\) bytes nên mình đổi tên quix thành key_second.

  3. Tiếp theo, splatted là XOR của phần cuối và phần thứ hai nên mình đổi tên thành third_X_second.

  4. Phần đầu key['flibber']\(8\) bytes nên mình đổi tên wiggle thành key_first.

  5. Tiếp theo, waggly là sắp xếp lại giá trị của key_first nên mình đổi tên thành key_first_srt.

  6. Mình bỏ qua zort

  7. Nhìn xuống một chút mình thấy hàm encrypt_block return bytes(plunk) nên có thể đoán plunk là bản mã, mình đổi tên thành ct.

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ự ij đều được chuyển thành chuỗi bit \(01000\), tương tự, kí tự uv đề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\)\(g\). Chọn \(a \in [p - 10, p]\)\(b \in [g - 10, g]\).

Đề bài tính \(u = g^a \bmod p\)\(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\)\(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\(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\)\(\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_predictorExtendMT19937Predictor.

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.