はまやんはまやんはまやん

hamayanhamayan's blog

TJCTF 2025 Writeups

[forensics] hidden-message

i found this suspicious image file on my computer. can you help me figure out what's hidden inside?

suspicious.pngというファイルが与えられる。青い空を見上げればいつもそこに白い猫を起動し、ステガノグラフィー解析でパラパラと見てみると幾何学的な模様の他に怪しい点々が見られる。LSBとかに情報を埋め込むやつっぽいのでzstegで検索するとフラグがある。

$ zsteg -a suspicious.png | grep tjctf
b1,rgb,lsb,xy       .. text: "tjctf{steganography_is_fun}###END###"

[forensics] deep-layers

Not everything ends where it seems to...

chall.pngというファイルが与えられる。とりあえずstringsしてみるとbase64エンコード文字列みたいなものと、secret.gzというのが見える。

$ strings chall.png 
IHDR
%iTXtPassword
cDBseWdsMHRwM3NzdzByZA==
IDATx
c````
IEND
secret.gzUT
Bhux
CU\d
secret.gzUT
Bhux

base64エンコード文字列をデコードするとp0lygl0tp3ssw0rdとなった。polyglotということで、pngファイルをそのまま解凍すると、パスワードが要求されるので先ほどのデコードされた文字列を入れると、回答できsecret.gzが得られる。それを更に解凍するとフラグが書いてある。

[forensics] footprint

The folder used to hold some important files — including one with the flag as its name. Unfortunately, all the files were deleted. Can you piece together the flag from what's left behind?

files.zipというファイルが与えられ、解凍すると.DS_Storeが置いてある。解析しよう。https://github.com/hanwenzhu/.DS_Store-parser で解析してみると以下のようになった。

$ python3 parse.py ../.DS_Store
-FumtF3yx-kSP11OD8mFPA
        Icon location: x 175px, y 46px, 0xffffffffffff0000
0wnNJd_pKKNtfhG-HL8iJw
        Icon location: x 285px, y 46px, 0xffffffffffff0000
1VmhSaBo9ymK5dUhB3cPEQ
        Icon location: x 395px, y 46px, 0xffffffffffff0000
1zp7dw6eF3co0VaPDKhUag
        Icon location: x 505px, y 46px, 0xffffffffffff0000
27bCy1Bt-9nnLG4W8oxkNA
        Icon location: x 65px, y 270px, 0xffffffffffff0000
4vsxjPs-c9hBNmmaE8HJ8Q
        Icon location: x 615px, y 46px, 0xffffffffffff0000
...
XfjqZgZvquXzdnfbcMKQMA
        Icon location: x 505px, y 1726px, 0xffffffffffff0000
Xnji8EXzCRLmKJvoAkZftA
        Icon location: x 615px, y 1726px, 0xffffffffffff0000
yfmS9_zOUIcxfSY-obWMhg
        Icon location: x 65px, y 1838px, 0xffffffffffff0000
ylFen4T5uMeqvJC6p8dfkA
        Icon location: x 175px, y 1838px, 0xffffffffffff0000
yx7GMqzc5YejMzOO5F087g
        Icon location: x 285px, y 1838px, 0xffffffffffff0000
Z3cpkGAUMlLgzQctzCo2Zg
        Icon location: x 395px, y 1838px, 0xffffffffffff0000

base64エンコードされた文字列っぽいのでそれぞれデコードして眺めると、フラグっぽいのが見つかる。

dGpjdGZ7ZHNfc3RvcmVfIA → "tjctf{ds_store_"
aXNfdXNlZnVsP30gICAgIA → "is_useful?}"

結合して出すと正解。

[crypto] bacon-bits

follow the trail of bacon bits... flag is all lowercase, with the format of tjctf{...}

以下のような暗号スクリプトとその結果が与えられる。

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]))

ベーコン暗号というのがあるらしい。復号スクリプトを書く。

with open('out.txt', 'r') as f:
    out_content = f.read().strip()

ciphertext = ''.join([chr(ord(char) + 13) for char in out_content])

baconian = {
'00000': 'a', '00001': 'b', '00010': 'c', '00011': 'd', '00100': 'e', 
'00101': 'f', '00110': 'g', '00111': 'h', '01000': 'i', '01001': 'k', 
'01010': 'l', '01011': 'm', '01100': 'n', '01101': 'o', '01110': 'p', 
'01111': 'q', '10000': 'r', '10001': 's', '10010': 't', '10011': 'u', 
'10100': 'w', '10101': 'x', '10110': 'y', '10111': 'z'
}

decoded_flag = ""
for i in range(0, len(ciphertext), 5):
    group = ciphertext[i:i+5]
    if len(group) == 5:
        pattern = "".join("1" if char.isupper() else "0" for char in group)
        decoded_flag += baconian.get(pattern, "?")

print(decoded_flag)

実行するとtictfoinkooinkoooinkooooinkとなる。出力通りだとtictf{oinkooinkoooinkooooink}だが、tjctfから始まることが分かっているので作問ミスかな?と思い微調整すると通る。tjctf{oinkooinkoooinkooooink}

[crypto] alchemist-recipe

an alchemist claims to have a recipe to transform lead into gold. however, he accidentally encrypted it with a peculiar process of his own. he left behind his notes on the encryption method and an encrypted sample. unfortunately, he spilled some magic ink on the notes, making them weirdly formatted. the notes include comments showing how he encrypted his recipe. can you find his "golden" secret?

暗号化スクリプトとその出力結果が与えられる。

暗号化スクリプト全体

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()

暗号化スクリプトで何をやっているかというと、

  1. キー生成 (glorbulate_sprockets_for_bamboozle): 固定文字列"AurumPotabileEtChymicumSecretum"のSHA256ハッシュから3つの要素を生成
  2. flibber: 最初の8バイト(置換用)
  3. twizzle: 次の16バイト(XOR用)
  4. drizzle: 残りのバイトで初期化したS-Box(置換テーブル)
  5. ブロック暗号化 (scrungle_crank): 8バイトブロックに対して、S-Box置換 → XOR → ソート置換の順で処理
  6. 全体暗号化 (snizzle_bytegum): PKCS7パディング追加、8バイトずつブロック暗号化

ということをしている。逆計算が実装可能なので、実装して以下のように解ける。

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 unscrungle_crank(encrypted_block, sprockets):
    wiggle = sprockets['flibber'] 
    waggly = sorted([(wiggle[i], i) for i in range(WUMBLE_BAG)])
    zort = [oof for _, oof in waggly]
    
    unsplatted = [0] * WUMBLE_BAG
    for y in range(WUMBLE_BAG):
        x = zort[y]
        unsplatted[x] = encrypted_block[y]
    
    quix = sprockets['twizzle']
    zonked = bytes([unsplatted[i] ^ quix[i % len(quix)] for i in range(WUMBLE_BAG)])
    
    reverse_drizzle = [0] * 256
    for i, val in enumerate(sprockets['drizzle']):
        reverse_drizzle[val] = i
    
    return bytes([reverse_drizzle[x] for x in zonked])

def unsnizzle_bytegum(encrypted_data, jellybean):
    decrypted = b""
    for b in range(0, len(encrypted_data), WUMBLE_BAG):
        encrypted_block = encrypted_data[b:b+WUMBLE_BAG]
        decrypted += unscrungle_crank(encrypted_block, jellybean)
    
    if decrypted:
        padding_length = decrypted[-1]
        if padding_length <= WUMBLE_BAG and padding_length > 0:
            if all(x == padding_length for x in decrypted[-padding_length:]):
                decrypted = decrypted[:-padding_length]
    return decrypted

encrypted_hex = "b80854d7b5920901192ea91ccd9f588686d69684ec70583abe46f6747e940c027bdeaa848ecb316e11d9a99c7e87b09e"
encrypted_data = bytes.fromhex(encrypted_hex)
jellybean = glorbulate_sprockets_for_bamboozle(SNEEZE_FORK)
decrypted_flag = unsnizzle_bytegum(encrypted_data, jellybean)
print(decrypted_flag.decode('utf-8'))

[forensics] album-cover

i heard theres a cool easter egg in the new tjcsc album cover

albumcover.pngとenc.pyというファイルが与えられる。enc.pyは以下のような感じ。

import wave
from PIL import Image
import numpy as np
#sample_rate = 44100
with wave.open('flag.wav', 'rb') as w:
    frames = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16)
    print(w.getnframes())
    sampwidth = w.getsampwidth() # 2
    nchannels = w.getnchannels() # 1
    w.close()
arr = np.array(frames)
img = arr.reshape((441, 444))
img = (img + 32767) / 65535 * 255
img = img.astype(np.uint8)
img = Image.fromarray(img)
img = img.convert('L')
img.save('albumcover.png')

wavがpngに埋め込まれているようだ。抽出スクリプトを書いて抽出すると、機械的な音声のwavが手に入る。Sonic Visualiserでスペクトラムを見てみるとフラグが書いてあった。

[web] TeXploit

I made a LaTex compiler that can generate pdfs. It even prints the log file if there is an error. The flag is located in /flag.txt.

ソースコード無し。 https://instancer.tjctf.org/challenge/texploit

LaTeXのコードを渡してPDFにするサイト。/flag.txtを持って来るのがゴール。適当にpayloadを試すとブラックリストがあることが知らされる。

\newtoks\in
\newtoks\put
\in={in}
\put={put}
\begin{\the\in\the\put}{/flag.txt}\end{\the\in\the\put}

ガチャガチャやっていると、これをやると/flag.txtが読み込まれてエラーに出てくる。

[crypto] theartofwar

"In the midst of chaos, there is also opportunity" - Sun Tzu, The Art of War

暗号文は以下で、実行結果も与えられる。

from Crypto.Util.number import bytes_to_long, getPrime, long_to_bytes
import time


flag = open('flag.txt', 'rb').read()
m = bytes_to_long(flag)

e = getPrime(8)
print(f'e = {e}')

def generate_key():
    p, q = getPrime(256), getPrime(256)
    while (p - 1) % e == 0:
        p = getPrime(256)
    while (q - 1) % e == 0:
        q = getPrime(256)
    return p * q
    
for i in range(e):
    n = generate_key()
    c = pow(m, e, n)
    print(f'n{i} = {n}')
    print(f'c{i} = {c}')

meが様々なnで行われている。実行結果よりe=229であることが分かっていて、は比較的小さい。また、e個分c[i]=m^e mod n[i]が与えられていることから、CRTで解ける。CRTを使えば、mod n[0]*n[1]*n[2]*...*n[228]の時のmeの値を求めることができる。ここまで法が大きくできれば、meは剰余が取られずそのままe乗された値が得られるので、単純に整数上としてe乗根を計算すればよい。これらの計算をした、以下のsagemathで解ける。

from Crypto.Util.number import long_to_bytes

with open('output.txt', 'r') as f:
    lines = f.read().strip().split('\n')

e = int(lines[0].split(' = ')[1])
n_values = []
c_values = []

for line in lines[1:]:
    if line.startswith('n'):
        n_values.append(int(line.split(' = ')[1]))
    elif line.startswith('c'):
        c_values.append(int(line.split(' = ')[1]))

m_pow_e = crt(c_values, n_values)
m = int(m_pow_e^(1/e))
print(long_to_bytes(m).decode())

[crypto] seeds

You can't grow crops without planting the seeds. (Server is hosted in the US eastern time zone)

暗号化コードは以下で、LCGを使って乱数を作成し、AES-ECBで暗号化している。

#!/usr/local/bin/python3.10 -u

import time, sys, select
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

class RandomGenerator:
    def __init__(self, seed = None, modulus = 2 ** 32, multiplier = 157, increment = 1):
        if seed is None: 
            seed = time.asctime()
        if type(seed) is int: 
            self.seed = seed
        if type(seed) is str: 
            self.seed = int.from_bytes(seed.encode(), "big")
        if type(seed) is bytes: 
            self.seed = int.from_bytes(seed, "big")
        self.m = modulus
        self.a = multiplier
        self.c = increment

    def randint(self, bits: int):
        self.seed = (self.a * self.seed + self.c) % self.m
        result = self.seed.to_bytes(4, "big")
        while len(result) < bits // 8:
            self.seed = (self.a * self.seed + self.c) % self.m
            result += self.seed.to_bytes(4, "big")
        return int.from_bytes(result, "big") % (2 ** bits)

    def randbytes(self, len: int):
        return self.randint(len * 8).to_bytes(len, "big")

def input_with_timeout(prompt, timeout=10):
    sys.stdout.write(prompt)
    sys.stdout.flush()
    ready, _, _ = select.select([sys.stdin], [], [], timeout)
    if ready:
        return sys.stdin.buffer.readline().rstrip(b'\n')
    raise Exception

def main():
    print("Welcome to the AES Oracle")
    
    randgen = RandomGenerator()
    cipher = AES.new(randgen.randbytes(32), AES.MODE_ECB)
    flag = open("flag.txt", "rb").read()

    ciphertext = cipher.encrypt(pad(flag, AES.block_size))
    print(f'{ciphertext = }')

    while True:
        plaintext = input_with_timeout("What would you like to encrypt? (enter 'quit' to exit) ")
        if plaintext == b"quit": break
        cipher = AES.new(randgen.randbytes(32), AES.MODE_ECB)
        ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))
        print(f"{ciphertext = }")



if __name__ == "__main__":
    main()

問題文に(Server is hosted in the US eastern time zone)のようにヒントもあるが、現在時刻をシードにしてLCGで乱数を作っている。現在時刻は推測可能であるため、シードを推測して乱数列を再現することで復号化ができるようになる。

それを実装したのが以下で、これを動かすとフラグが得られる。

#!/usr/bin/env python3

import time
from ptrlib import *
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from datetime import datetime, timezone

class RandomGenerator:
    def __init__(self, seed = None, modulus = 2 ** 32, multiplier = 157, increment = 1):
        if seed is None: 
            seed = time.asctime()
        if type(seed) is int: 
            self.seed = seed
        if type(seed) is str: 
            self.seed = int.from_bytes(seed.encode(), "big")
        if type(seed) is bytes: 
            self.seed = int.from_bytes(seed, "big")
        self.m = modulus
        self.a = multiplier
        self.c = increment

    def randint(self, bits: int):
        self.seed = (self.a * self.seed + self.c) % self.m
        result = self.seed.to_bytes(4, "big")
        while len(result) < bits // 8:
            self.seed = (self.a * self.seed + self.c) % self.m
            result += self.seed.to_bytes(4, "big")
        return int.from_bytes(result, "big") % (2 ** bits)

    def randbytes(self, len: int):
        return self.randint(len * 8).to_bytes(len, "big")

sock = remote("[redacted]", [redacted])

sock.recvline()
flag_line = sock.recvline().decode()
flag_ciphertext = eval(flag_line.split('ciphertext = ')[1])

known_plaintext = b"A" * 16
sock.sendlineafter("What would you like to encrypt? (enter 'quit' to exit) ", known_plaintext)
response = sock.recvline().decode()
known_ciphertext = eval(response.split('ciphertext = ')[1])

current_time = datetime.now(timezone.utc)

for offset in range(-300, 301):
    test_time = current_time.timestamp() + offset
    seed_str = time.asctime(time.gmtime(test_time))
    
    try:
        randgen1 = RandomGenerator(seed_str)
        flag_key = randgen1.randbytes(32)
        
        randgen2 = RandomGenerator(seed_str)
        randgen2.randbytes(32)
        known_key = randgen2.randbytes(32)
        
        cipher = AES.new(known_key, AES.MODE_ECB)
        expected = cipher.encrypt(pad(known_plaintext, AES.block_size))
        
        if expected == known_ciphertext:
            flag_cipher = AES.new(flag_key, AES.MODE_ECB)
            flag = unpad(flag_cipher.decrypt(flag_ciphertext), AES.block_size)
            print(flag.decode())
            break
    except:
        continue

sock.sendline("quit")
sock.close()

[crypto] close-secrets

I tried to make my Diffie-Hellman implementation a bit more interesting, but maybe I went too far? Can you make sense of this custom cryptography and decode the encrypted flag?

以下のようなことを行う、Diffie-Hellman鍵交換を使った暗号化スクリプトが与えられる。

  1. generate_dh_key(): 1024ビット素数p,gを生成し、秘密鍵a,bで公開鍵u,vを計算、共有鍵keyを生成
  2. dynamic_xor_encrypt(): フラグをバイト列にして逆順にし、共有鍵のSHA256ハッシュとXOR暗号化
  3. encrypt_outer(): XOR結果に対して (val + key_offset) * key で外側暗号化

これを使って、「flag → バイト列逆順 → SHA256(共有鍵)とXOR → 共有鍵で線形変換」みたいな感じで暗号化する。

暗号化スクリプト全体

import random
from random import randint
import sys
from Crypto.Util import number
import hashlib 

def encrypt_outer(plaintext_ords, key):
    cipher = []
    key_offset = key % 256
    for val in plaintext_ords:
        if not isinstance(val, int):
            raise TypeError
        cipher.append((val + key_offset) * key)
    return cipher

def dynamic_xor_encrypt(plaintext_bytes, text_key_bytes):
    encrypted_ords = []
    key_length = len(text_key_bytes)
    if not isinstance(plaintext_bytes, bytes):
        raise TypeError
    for i, byte_val in enumerate(plaintext_bytes[::-1]):
        key_byte = text_key_bytes[i % key_length]
        encrypted_ords.append(byte_val ^ key_byte)
    return encrypted_ords

def generate_dh_key():
    p = number.getPrime(1024)
    g = number.getPrime(1024)
    a = randint(p - 10, p)
    b = randint(g - 10, g)
    u = pow(g, a, p)
    v = pow(g, b, p)
    key = pow(v, a, p)
    b_key = pow(u, b, p)
    if key != b_key:
        sys.exit(1)
    return p, g, u, v, key

def generate_challenge_files(flag_file="flag.txt", params_out="params.txt", enc_flag_out="enc_flag"):
    try:
        with open(flag_file, "r") as f:
            flag_plaintext = f.read().strip()
    except FileNotFoundError:
        sys.exit(1)
    flag_bytes = flag_plaintext.encode('utf-8')
    p, g, u, v, shared_key = generate_dh_key()
    xor_key_str = hashlib.sha256(str(shared_key).encode()).hexdigest()
    xor_key_bytes = xor_key_str.encode('utf-8')
    intermediate_ords = dynamic_xor_encrypt(flag_bytes, xor_key_bytes)
    final_cipher = encrypt_outer(intermediate_ords, shared_key)
    with open(params_out, "w") as f:
        f.write(f"p = {p}\n")
        f.write(f"g = {g}\n")
        f.write(f"u = {u}\n")
        f.write(f"v = {v}\n")
    with open(enc_flag_out, "w") as f:
        f.write(str(final_cipher))

if __name__ == "__main__":
    try:
        with open("flag.txt", "x") as f:
            f.write("tjctf{d3f4ult_fl4g_f0r_t3st1ng}")
    except FileExistsError:
        pass
    generate_challenge_files()

generate_dh_keyの実装に問題がある。

def generate_dh_key():
    p = number.getPrime(1024)
    g = number.getPrime(1024)
    a = randint(p - 10, p)
    b = randint(g - 10, g)
    u = pow(g, a, p)
    v = pow(g, b, p)
    key = pow(v, a, p)
    b_key = pow(u, b, p)
    if key != b_key:
        sys.exit(1)
    return p, g, u, v, key

秘密鍵であるa,bが10通りしかないので、全探索すればshared_keyが復元できる。あとは復号化コードを書いて復号するだけ。ソルバーは以下。

import hashlib

p = 1704143...redacted...2527
g = 1206811...redacted...2283
u = 1356687...redacted...4706
v = 1650367...redacted...0749

enc_flag = [6571...redacted...4720]

for i in range(11):
    a = p - i
    if pow(g, a, p) == u: break
for i in range(11):
    b = g - i
    if pow(g, b, p) == v: break

shared_key = pow(v, a, p)

def decrypt_outer(cipher, key):
    key_offset = key % 256
    return [(c // key if c != 0 else 0) - key_offset for c in cipher]

def dynamic_xor_decrypt(encrypted_ords, shared_key):
    xor_key = hashlib.sha256(str(shared_key).encode()).hexdigest().encode()
    return [(ord_val ^ xor_key[i % len(xor_key)]) & 0xFF for i, ord_val in enumerate(encrypted_ords)]

intermediate = decrypt_outer(enc_flag, shared_key)
decrypted = dynamic_xor_decrypt(intermediate, shared_key)
flag = bytes(decrypted[::-1]).decode('utf-8', errors='ignore')

print(flag)

[forensics] packet-palette

Someone tried to reinvent USB-over-IP… poorly. Can you sift through their knockoff protocol?

chall.pcapngが与えられる。中身を目grepすると、データ部分にPNGファイルが埋め込まれているように見える。以下のようにいい感じにskipしたりして取り出してくるとフラグが書かれたpngファイルが得られる。

$ tshark -r chall.pcapng -Y 'tcp.port == 31337 and frame.number >= 2' -T fields -e data | sed 's/^.\{24\}//' | tr -d '\n:' | xxd -r -p > out.png