做了很久pwn题,但是还没有系统的对 glibc 源码进行过分析与总结。这样的感觉,总觉得自己学得东西很片面,不够系统,也不够牢固。所以觉得自己还是需要花一些时间好好研究一下源码,特别是堆机制的重点。虽然自己写代码很烂,看代码的能力也很烂,但是加油吧。
源码调试
之前在windows
里用windbg
可以使用调试符号,对于调试很方便,所以就想到linux
下应该也有。结果 glibc
更爽直接有源码,加入 Glibc
源码调试的方法吧,这样可以迅速定位自己报错的原因,对于glib里的各项检查,也能够有更清晰的认识吧。
首先下载源代码和调试符号:(下面示例是2.23的)
1 | sudo apt-get install glibc-source |
随后在 pwndbg
里输入,就能加载malloc
文件夹下的源代码了:
1 | pwndbg> directory /usr/src/glibc/glibc-2.23/malloc/ |
然后就可以在 malloc
或者 free
等函数里下断点了。
基本结构
malloc_chunk
由 malloc 申请的内存为chunk,其申请的数据结构为 malloc_chunk 结构体来表示。当一个chunk 执行 free 函数时,malloc_chunk 会被加入到相应的空闲管理列表。malloc_chunk 结构体如下,其中我加入了中文注释,更细节的讲解可以参考https://ctf-wiki.github.io/ctf-wiki/pwn/linux/glibc-heap/heap_structure-zh/。
1 | struct malloc_chunk { |
malloc_stats
每个分配区即一个 malloc_state 结构体实例,ptmalloc
使用这个结构来管理每一个分配区,而参数的管理使用的是 malloc_par
结构体,全局拥有一个该结构体的实例。关于此结构体的讲解,可以参考https://www.lyyl.online/2019/09/28/malloc%E5%92%8Cfree%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/。
1 | struct malloc_state { |
分配区是为了加速多线程分配内存而引入的,主分配区和子分配区形成一个环形链表,每一个线程都存在一个私有的变量存放分配区指针,在分配内存的时候,查看哪个分配区没有上锁,即在哪个分配区分配内存,如果分配区全部被占用,则建立一个新的分配区。
主分配区 可使用 sbrk 和 mmap 两种分配方式,相对的子分配区则是预先使用 mmap 申请一块较大的内存。关于 sbrk 和 mmap 的区别,会在 house of orange 中需要注意。
1 | /* offset 2 to use otherwise unindexable first 2 bins */ |
SIZE_SZ
在64位中是 64 位无符号整数,32位中是 32 位无符号整数。
那么对于 64 位系统 MAX_FAS_SIZE
= 160字节,32系统 MAX_FAST_SIZE
= 80 字节;此处的数据大小都是不包含 chunk 头部的。
根据请求的 sz 计算 fastbin_index
是通过 将 sz 64位系统左移4位,32位系统左移 3 位,减去 2。减2 是因为 fastbin 最小的 CHUNK_SIZE 不是从 0 开始的,而是 64位从 0x20 开始,32位从 0x10 开始。
bins
变量中存储了 unsortedbin, small bin(62), large bin(63)
的链表头指针,其中 bins[0],bins[127]
都不存在,bins[1]
中存储了 unsortedbin
的 chunk
的链表头部,mchunkptr
是 malloc_chunk
的结构体指针。
1 | mchunkptr bins[ NBINS * 2 - 2 ]: |
- small bin:根据用户请求的 sz 大小得到 small bin的下标的方法是:
chunk_size = MALLOC_ALIGNMENT * indx
,其中MALLOC_ALIGNMENT = 2 * SIZE_SZ
。所以对于 32位,最大 chunk 为 504字节,对于64最大为 1008 字节。
1 | /* |
- large bin:共包含
63
个 bin,每个 bin 中的chunk 大小不一致,而是在一定范围之内,63个 bin 被分成 6 组,每组 Bin 的 chunk 的大小之间的公差一致。
largebin
排列方式从大到小依次排列,链表头的 bk
指针指向最小的堆块:
1 | //根据sz计算其处于的不同large bin链表,最终得到最适合的large bin |
- binmap 中的每一位表示对应的 bin 中是否存在空闲的 chunk,4 个 block 来管理,每个 block 有 4 字节,也就是 128个 bit
malloc_par
1 | struct malloc_par { |
thrim_threshold
字段表示收缩阈值,默认为128KB
,当top chunk
的大小大于这个阈值时,再调用 free 函数的时候可能会缩小 top chunk,收缩内存。收缩阈值可以通过mallocopt
函数设置,由于 mmap 分配阈值动态调整,free 函数最大可以将收缩阈值设置为分配阈值的 2 倍。mmap_threshold
表示分配阈值,默认值为128KB
,32
位系统中最大值位512KB
,64
系统中的最大值为32MB
。默认开启mmap
分配阈值的动态调整,但不会超过最大值arena_test
和arena_max
,当每个进程中的分配区数量大于arena_max
的时候不会创建新的分配区,当分配区数量小于arena_test
的时候不会重用现有的分配区。
heap_info
1 | typedef struct _heap_info |
该结构体主要是为了非主线程分配内存使用,也即在非主分配区分配内存。因为在主分配区内存可以直接使用 sbrk
方式拓展内存,只有一个堆。而非主线程的 heap
是提前分配好的,因此当该 heap
资源用完时,就需要重新申请内存空间,因为一般情况下申请的内存空间是不连续的,因此需要记录一个线程中不同的 heap
的链接结构。
ar_ptr
表示该堆所对应的分配区prev
该线程的上一个堆结构,注意到这里是采用单链表的形式来记录一个线程的所有堆结构的。size
堆的大小
重要函数
malloc
1 | void *__libc_malloc(size_t bytes) { |
每一步的注释,都以加到上面代码中。
我们需要关注,当执行 malloc 时,如果 malloc_hook
有值, 程序会先执行 malloc_hook
所指向的函数。这就给我们 getshell 提供了一种思路。就是修改 malloc_hook
的值 为一个 gadget值,然后再执行 malloc 函数 就能够 getshell。
_int_malloc
1 | static void *_int_malloc(mstate av, size_t bytes) { |
首先是会检查用户的请求的size 大小,然后再去检查是否 有可用的 malloc_state 分配区,如果没有,则调用 sysmalloc
使用 mmap 函数分配。
如果第一次调用 malloc
时 malloc_hook
不为空其值为 malloc_hook_ini
函数,通过调用 _init_malloc
实现一些初始化操作。
随后系统会遍历查找 fastbin
fastbin
1 | /* |
check_remalloced_chunk
函数对 chunk 结构体的标志位进行了检查,主要有 该 chunk 是否是 malloc_state
所表示的分配区,该 chunk 是否已经分配,是否重复分配和一些大小和地址检查。
1 | static void do_check_remalloced_chunk(mstate av, mchunkptr p, |
如果分配出错,会通过 malloc_printerr
函数打印错误信息。会调用 __libc_message
函数输出,调用 abort 函数退出程序。
1 | static void malloc_printerr(int action, const char *str, void *ptr, |
而在 abort
函数里,在 glibc 2.23 版本 会调用 fflush stream,而这也是 FSOP 攻击的原理,通过 abort
函数直接调用 fflush
函数,最后调用_IO_FILE_plus.vtable 中的 _IO_overflow,实现 getshell。
1 | /* Flush all streams. We cannot close them now because the user |
如果 fastbin 找不到,随后会去 small bin 中寻找。
small bin
1 | /* |
从代码中检查 small bin是否为空的操作,即 初始化的 small bin
的 bk 指针是指向自身的。
从最后分配 small bin的操作可知,small bin 头部 chunk 的 bk 指针指向 链表尾部 chunk,链表尾部 chunk 的 fd 指针指向 链表头部。
当 small bin
没有初始化时,所有的指针均为空,这时程序就会调用 malloc_consolidate
函数将 fastbin 链表中的所有 chunk
进行合并,并将合并后的 chunk
插入到 unsortedin
中。
如果small bin中不满足,则会进入 large bin。
large bin
1 | //获取请求的size在large bin中的下标 |
随后进入 unsortedbin 处理:
1 | //进入unsortedbin 处理 |
代码比较长,主要步骤如下:
1 | 1. 查找unsortedbin 是否为空 |
注意,当取得unsorted bin chunk与我们申请的chunk 大小相同时,程序进行了如下操作:
1 | /* remove from unsorted list */ |
如果我们能控制 av 的bk指针,那么就能向 bk 的 fd 指针写入 av的值,发生 unsortedbin attack。
当unsorted bin中没有合适的chunk时,则会遍历查找 请求的size 所对应的 bin中chunk
1 | //large bin |
如果用户申请的size所对应的large bin中不存在符合要求的chunk的情况,则在其他 size 的large bin中进行寻找。
1 | /* |
如果符合用户请求的size 的bins 中没有满足要求的 chunk,则会继续在 large bin中寻找。不断遍历,直到找到符合要求的chunk,随后进行 chunk 划分等操作。
如果 large bin 中遍历结束,仍然没有找到相应的 chunk,则需要在 top chunk中寻找。
top chunk
1 | use_top: |
最后就是在top chunk进行分配,如果top chunk满足,则划分top chunk。如果不满足,首先会进行合并fastbin到large bin或者 small bin中,在继续进行遍历。如果没有fastbin可合并,程序会直接调用 sysmalloc
函数进行分配区分配。
sysmalloc
函数如果满足如下条件
1 | old-top chunk size 必须要对齐到内存页 |
系统就会调用 brk 分配新的分配区。则为 House of orange
攻击做好了准备。
总结
可参考 LYYL https://www.lyyl.online/2019/09/28/malloc%E5%92%8Cfree%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/ 博客总结,很全面,也没有错误。(这人写了8万字,我真想推荐他出本书。以后打比赛就靠他带了,逃。。。)
free
1 | void __libc_free(void *mem) { |
主要对 _int_free
函数进行分析
_int_free
1 | /* Little security check which won't hurt performance: the |
首先会对要被释放的chunk的size进行检查,查看size是否大于MINSIZE,地址是否被对齐。
随后首先进入 fastbin的判断,如果size属于fastbin 则将要被释放的chunk放入对应的fastbin链表中。
fastbin
1 | /* |
如果不属于fastbin,则会判断进入unsortedbin中:
unsortedbin
1 |
|
这一段代码主要是将chunk释放到unsorted bin时,会先检查当前chunk在next chunk的prev_inuse位是否被置为1,即判断要被释放的chunk是否在使用。然后会检查其prev chunk是否被释放,如果被释放,则会与prev chunk合并,并且对prev chunk执行 unlink操作,其寻找prev chunk的堆头是通过 prev_size查找。然后检查next chunk是否被释放,如果被释放,则将其与next chunk合并,执行unlink操作,寻找next chunk是通过 chunk size查找。
unlink操作如下:
unlink
1 | /* Take a chunk off a bin list */ |
可以看到 unlink
首先检查了 chunk 的前后指针是否指向 P,然后执行了 解链操作。
这里就存在 unlink
攻击,导致我们可以 伪造 P 的 fd 和 bk 指针,将一个 内存地址 ptr 的值 修改为 ptr-0x18 的值。
1 | /* |
总结
- 首先检查 free_hook是否被初始化,如果被初始化则执行 free_hook指向的函数地址(存在通过free_hook来getshell);为被初始化进入下一步;
- 如果chunk是通过 mmap分配,则通过 munmap函数释放chunk;否则调用 _int_free 函数,进入下一步:
- 对传入的指针和其指向的 chunk size 进行合法性验证,判断传入的 chunk 为 inuse;
- 若 chunk size 的大小满足 fast bin的大小范围,则在经过指针和size的合法性验证之后 将 chunk 插入到 fastbin链表中。若 chunk 不是由 mmap 分配的,则进入下一步,否则进入第7步。
- 此时 chunk size的大小 超过fastbin的规定范围,将 chunk 与物理相邻的前一个chunk 进行前向合并,与物理相邻的后一个 chunk(包含 top chunk)进行后向合并,合并后的 chunk插入到 unsorted bin链表中,进入下一步;
- 判断释放的 chunk size的大小,超过阈值之后收缩内存;
- chunk 是通过 mmap 分配的,调用 munmap 释放 chunk。
realloc
realloc(void *p, size_t n)
,拓展已经分配的内存空间。如果p
指向的chunk
存在连续的空闲空间,则扩展p
指向的chunk
。否则分配一块新的chunk
,并将p
指向的chunk
中的数据拷贝到新chunk
中释放原有的chunk
,返回新chunk
指针。当p
为空时,realloc
等价于malloc
。如果新的size
小于原有的size
则只会拷贝size
大小的数据。
1 | void *__libc_realloc(void *oldmem, size_t bytes) { |
如果原有chunk不是通过mmap 分配的,则系统会调用 _int_realloc
函数来执行拓展内存的操作。如果调用失败,则在尝试在其他的 arena
处分配内存。
_int_realloc
1 | void *_int_realloc(mstate av, mchunkptr oldp, INTERNAL_SIZE_T oldsize, |
_int_realloc
,主要有:
判断现有chunk 的下一个next chunk是否空闲,如果空闲直接将现有 chunk 拓展到 next chunk即可;
如果next chunk是 top chunk也可以直接拓展;
如果next chunk是使用状态,则直接调用 _int_malloc 分配新的 chunk;
如果新的 chunk 是紧邻 的原有 chunk,则直接将原有chunk 也合并到新的 chunk;
拷贝原有数据到新的chunk
释放原来的chunk
如果新的 chunk大于用户请求的size,则实行 堆块划分,将多余剩下的chunk 释
malloc_consolidate
主要作用是对堆进行初始化,以及将 fastbin
中的空闲 chunk 合并到 unsorted bin
1 | /* |
- 首先通过
get_max_fast
判断堆是否初始化,若为初始化,则初始化堆,否则进入下一步 - 将
malloc_state
中表示fast bin 中含有 空闲chunk
的标志位清空,进入下一步 - 对
fast bin
中的每个chunk
进行下列操作:- 与其物理相邻的chunk 进行前向合并
- 若与
top chunk
相邻,则与top chunk
进行合并 - 与其物理相邻的
chunk
进行后向合并
Glibc 2.26 Tcache
基本结构
tcache_entry
tcache_entry
用于链接空闲的 chunk 结构体,其中的 next
指针指向下一个大小相同的 chunk。
此处 next
指针指向 chunk 的 user data,而不是fastbin 的fd 指向堆头。
1 | /* We overlay this structure on the user-data portion of a chunk when |
tcache_perthread_struct
每个线程都会维护一个 tcache_perthread_struct
结构,他是整个 tcache 的管理结构,一共有 TCACHE_MAX_BINS
个计数器和 TCACHE_MAX_BINS
项 tcache_entry。
1 | /* There is one of these for each thread, which contains the |
tcache_entry
用单链表的方式链接了相同大小的处于空闲状态(free 后) 的chunk,与 fast bin类似;
counts
记录了 tcache_entry
链上空闲 chunk 的数目,每条链上最多可以有 7 个 chunk
如下图所示:(图来自:CTF WIKI)
重要函数
__libc_malloc
相比于 2.23版本,2.26版本的 _libc_malloc 会首先进入 tcache 判断是否能分配tcache,先于 原有的 fastbin分配。
1 | void * |
在 tcache为空,即第一次调用 __libc_malloc
时, 函数 MAYBE_INIT_TCACHE
会调用 tcache_init
函数 对 tcache进行初始化。
1 | tcache_init(void) |
接下来就是从 tcache中申请内存:
1 | // 从 tcache list 中获取内存 |
当tcache 不为空时,会使用 tcache_get
函数来获取 tcache。 如果 tcache
为空,则进入与 2.23一致的 内存申请流程。
tcache_get()
1 | /* Caller must ensure that we know tc_idx is valid and there's |
tcache_get
流程比较简单,而且可以看到从 tcache链表中,获取tcache没有做太多保护,仅仅检查了 链表idx 和链表非空。那么我们从 tcache 中获取 伪造的 chunk 将会特别简单。
__libc_free
1 | void |
在 _libc_free
函数中并没有太多的 变化,重点变化是在 _int_free
函数中。
_int_free
1 | static void |
判断 tc_idx
合法,tcache->sounts[tc_idx]
在 7 个以内,就进入 tcache_put()
,传递的两个参数是要释放的 chuk 和 该 chunk 对应的 size 在 tcache中的下标。
tcache_put()
1 | /* Caller must ensure that we know tc_idx is valid and there's room |
tcache_puts
完成了把释放的 chunk 插入到 tcache->entries[tc_idx]
链表头部的操作,也没有太多的保护,没有把 P 位置为 0,也就是存在 free 伪造块 和 double free 漏洞。
漏洞原因分析
off-by-one
1.溢出字节为可控制任意字节:通过修改大小造成块结构之间出现重叠,从而泄露其它块数据,或是覆盖其他快数据。
原理:由于向前合并和向后合并是 通过本块 chunk的size和 prev_size找到的,如果伪造 size 或者 prev_size 就能向前覆盖 或者向后覆盖。
2.溢出自己为NULL字节:(1)覆盖 prev_in_use位 为0,会让前块被认为是 free块,在free当前块时会与前块进行合并,前块会执行 unlink 解链操作。unlink操作存在漏洞;(2)当prev_in_use位被置为0,prev_chunk的 prev_size 会存储前块的大小,通过伪造prev_size 实现向前合并到指定地址。
原理: 由于 prev_inuse 标识前一块是否被释放,执行堆合并时,前块不是fastbin时 会执行 unlink操作,unlink存在漏洞。 堆合并时向前合并是通过 prev_size 和本块的 size 向前找到前一块的堆头,如果伪造 prev_size 和前一块堆头,就能使得 向前合并 到我们想要的地址。并且合并时,没有检查前一个 块的 size 是否与 prev_size 相同。
Chunk Extend
对 inuse 的fastbin 进行 extend
申请两个 fastbin chunk1 和 chunk2,大小都为 0x21,如果修改 chunk1 的大小为 0x41,然后释放chunk1。那么再申请一个 0x41 的chunk3,chunk3 就能够覆盖 chunk2 的数据。
原理: free fastbin 堆块时,只检查当前堆块是否 在使用状态,通过 chunk_ptr + chunk_size 得到相邻的下一个chunk的堆头的 prev_inuse位来检查;所以修改 当前 chunk 的size 后,只要能保证其物理相邻的下一个chunk的 prev_inuse 被置为1 ,就能对该 fastbin进行释放。
对 inuse 的 smallbin 进行 extend
申请一个 chunk1 大小为 0xa1, 申请 chunk2 大小为 0x21,申请 chunk3 大小为 0x21(隔断 top chunk)。修改 chunk1 的size 为 0xc1,并释放该chunk。最后再申请 chunk 3 大小为 0xc1,那么 chunk3 就能够覆盖 chunk2的数据区。
原理: 释放 一个 smallbin时,会先将 chunk 放入 unsortedbin,并且执行前向 合并和后向合并。只要保证 当前 chunk 的 prev_inuse 位被置为1,则不会进行前向合并。保证 通过 fake_chunk_size 找到的 后一个 chunk 的 堆头 正确,即 prev_inuse 被置为1,则成功。
对 free 的 smallbin 进行 extend
一个 释放的 smallbin chunk1 的size 为 0xa1,其相邻的 在使用的 chunk2 大小为 0x21,chunk3 也在使用状态。然后 修改 chunk1 的size 为 0xc1,然后分配 0xc1 的chunk4,那么 chunk4 就能够覆盖chunk2。
原理:
通过extend 后向 overlapping
1 | int main() |
原理: 释放一个 堆块时 仅通过 当前 chunk 的 size 找到后一个堆块的 prev_inuse位在使用;申请一个fastbin 堆块时,先检查了 fastbin 中链表是否有满足大小的 chunk。
通过 extend 前向 overlapping
1 | int main(void) |
原理: 释放大于 fastbin 的堆块时,会进行前向和 后向合并。前向合并 仅检查 当前堆块的 prev_inuse 确定前一个堆块为 释放状态,通过 prev_size 找到前一个 堆块头,然后将 前一个 堆块 unlink,然后 堆块合并 返回。
Unlink
在大于 small bin 的堆块 free 时,会检查其 前一个堆块 和后一个 堆块是否在释放状态,然后执行前向合并 和 后向合并。合并时,会执行 unlink 操作,unlink操作 需要用到 Prev chunk 的 fd 和 bk 指针执行如下操作:
1 | //修改FD 和 BK的bk和fd指针 |
unlink 检查为:
1 | // fd bk |
通过如下绕过:
1 | 32位: |
Fastbin Attack
Fastbin Double Free
1 | int main(void) |
原理:
- fastbin 的堆块被释放后 next_chunk 的 pre_inuse 位不会被清空
- fastbin 在执行 free 的时候仅验证了 main_arena 直接指向的块,即链表指针头部的块。对于链表后面的块,并没有进行验证。
House of Spirit
在目标位置伪造 fastbin chunk,并将其释放,从而达到分配指定地址的 chunk 的 目的。
伪造条件:
- fake chunk 的 ISMMAP 位不能为 1,因为 free 时,如果是 mmap 的 chunk,会单独处理。
- fake chunk 地址需要对齐, MALLOC_ALIGN_MASK
- fake chunk 的 size 大小需要满足对应的 fastbin 的需求,同时也得对齐。
- fake chunk 的 next chunk 的大小不能小于
2 * SIZE_SZ
,同时也不能大于av->system_mem
。 - fake chunk 对应的 fastbin 链表头部不能是该 fake chunk,即不能构成 double free 的情况。
原理: free 函数 free 一个堆块时,只会对该堆块进行如上检测,将检测都符合 那么就会正常释放 该堆块。然后即可正常 malloc 出来。
Alloc to Stack & Arbitrary Alloc
劫持一个 释放的 fastbin 的 fd 指针到我们指定的目的地址,只需要在目的地址伪造 满足条件的 堆头,fake size满足 fastbin 大小,fake chunk的堆头地址是对齐的。
Unsorted bin Attack
释放一个 chunk 到 unsortedbin中,然后修改该 chunk 的 bk 指针指向我们 想要修改的 内存地址-0x10,然后分配该大小的 chunk,就会将我们 想要修改的 内存地址设置为 unsortedbin 堆块的地址。
原理:当分配一个恰好合适的 unsortedbin 时,会执行以下代码:
1 | /* remove from unsorted list */ |
Tcache Attack
tcache poisoning
修改 tcache 结构的 next 指针 为我们想要的 内存地址,而且没有了 对伪造chunk size的限制。能够成功在想要的内存地址 伪造堆块。
tcache dup
因为 tcache_put
对 free tcache 时也没有太多检查,所以 可以对同一个 tcache 释放两次,造成double free 漏洞。
tcache perthread corruption
tcache_prethread_struct 也在堆上,可以通过 掌控 这个结构来 掌管整个 malloc 的情况
tcache house of spirit
在栈上伪造块的 size,然后释放该地址,即能够将该地址放入 tcache 链表内,再分配出来 即可控制该 stack 地址。
smallbin unlink
在 smallbin 中包含有空闲块的时候,会同时将同大小的其他空闲块,放入 tcache 中,此时也会出现解链操作,但相比于 unlink宏,缺少了 链完整性检验,因此原本的 unlink 操作在该条件下也可以使用。
tcache stashing unlink attack
这种攻击利用的是 tcache bin 有剩余 (数量小于 TCACHE_MAX_BINS
) 时,同大小的 small bin 会放进 tcache 中 (这种情况可以用 calloc
分配同大小堆块触发,因为 calloc
分配堆块时不从 tcache bin 中选取)。在获取到一个 smallbin
中的一个 chunk 后会如果 tcache 仍有足够空闲位置,会将剩余的 small bin 链入 tcache ,在这个过程中只对第一个 bin 进行了完整性检查,后面的堆块的检查缺失。当攻击者可以写一个 small bin 的 bk 指针时,其可以在任意地址上写一个 libc 地址 (类似 unsorted bin attack
的效果)。构造得当的情况下也可以分配 fake chunk 到任意地址。
参考文献
https://www.lyyl.online/2019/09/28/malloc%E5%92%8Cfree%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/
https://ctf-wiki.github.io/ctf-wiki/pwn/linux/glibc-heap/implementation/tcache-zh/
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2020/09/28/glibc-malloc源码分析/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!