好不容易学点东西赶紧记下来, 不然过几天又忘记了
前情提要:
1. 由于glibc 2.34开始, 去掉了常用的各种hook, 包括__malloc_hook, __free_hook, __exit_hook等, 标志了一个时代的落幕. 从此之后, 在没有什么特别的backdoor的情况下, 仅仅使用tcachebin, unsortedbin, fastbin等的攻击很难达到劫持执行流的目的, 所以这些方法现在更多是作为一个泄露偏移的存在
2. 失去了hook不代表堆题就没法劫持控制流了(不然还玩个蛋), 还可以寻找其他劫持的方法. 劫持方法需要满足: 泛用性, 即大多数的情况下都存在的利用方法; 简易性, 在较少的漏洞利用的情况下就可以实现
3. 在现有的诸多劫持方法中, 可以总结出一些经验, 就是 largebinAttack + 某种house. 其中largebinAttack手法用于预备一个ROP(或者别的什么), 以及伪造一个IO_FILE_PLUS结构体, 然后由某种手法将控制流交给ROP
年轻人的第一个largebin attack
对于一个chunk, 当被free的时候, 如果大小小于tcachebin的上限的话, 被放进对应的tcachebin内, 如果大于的话, 会被放到unsorted bin内. 显然, 我们现在讨论后者.
放进unsorted bin的大chunk, 会在下一次malloc时被决定自己的命运. 当malloc无法在tcache和fastbin内找到合适的chunk(当前bins中的chunk都太小), 它会遍历unsorted bin
假如malloc依然无法找到, 同时目标的large bin没有和附近的free chunk或者是top chunk合并, 那么它就会被原封不动的放到一个large bin内
largebin具有和其他的bins不同的构造, 对于比较小的chunk, 每个chunk size都相对常用, 所以都有对应的bin. 但是large chunk本身就不常用, 具体落到每个chunk size更少, 所以glibc做法是一定chunk size内划分一个bin, 如图
1 |
|

上面一行0x400~0x430虽然是这么写, 但实际上只有chunksize > 0x420, 也即malloc(0x410)以上才会放到largebin
一个largebin内chunksize是非升序排列的, 也就是从大到小的趋势 (图中从左向右, main_arena作为链表的尾) , 大小相同的紧挨着
largebin chunk内部的存放有管理这个链表的信息, fd, bk, fd_nextsize, bk_nextsize, fd, bk和其他bin没有区别, 连接着该bin中的所有chunk, 以及该bin所对应的main_arena
再看一个码, 大概意思就是一个chunksize有两个, 一个bin内一共6个
1 |
|

给大🔥们画一个, 但是手太僵了

由fd, bk, 连接起了全部chunk和main_arena, 这也是gdb上展示的顺序
其次fd_nextsize, bk_nextsize, 只有每一组大小相同的chunks中的第一个才有这两个内容, 并且不连接main_arena
特别地, 当一个bin中只有两组不同大小的chunks时, 一个组的fd_nextsize, bk_nextsize都指向另一组(因为双向链表); 只有一组时, 这对指针都会指向自己
fd_nextsize, bk_nextsize是专门用于管理同一个large bin中不同大小的chunk的排列的, 这一组指针和上一组不同, 并不会连接main_arena
large bin attack主要攻击的是fd_nextsize, bk_nextsize这一组指针
看一段glibc源码
1 |
|
有问题的语句在victim->bk_nextsize = fwd->bk_nextsize和victim->bk_nextsize->fd_nextsize = victim;, 即当找不到一个相同size的chunk, 目标victim必须生成一对nextsize, 来管理它自己size大小的large chunks, 问题在于缺少对于fwd->bk_nextsize的检查, 它实际上有可能被篡改为其他地址
现给出一个实现该large bin attack的最小利用
1 |
|
p1[3] = &a[0]之后, bk_nextsize变成了&a[0]的形状

第二个malloc(0x440)之后, 触发了attack

检查a[4], 发现确实篡改, 并且堆地址是

具体发生了什么, 请看PNG

所以不难总结出部署一次largebin attack的方法:
1.准备一个chunk1, free掉, 它将作为之后源码中的fwd
2.申请一个比chunk1大的堆块, chunk1就被放在large bin中
3.UAF或者堆溢出, 修改chunk1的bk_nextsize为你指定的地址target的低0x10, 即target - 0x20
4.申请一个chunk2, 它比chunk1小, 但是应该被放在同一个bin, free它, 作为源码中的victim存在
5.重复2所做的事, 这会触发largebin attack, 并在target位置写上victim的chunkhead的地址
__malloc_assert劫持控制流
劫持路径
__malloc_assert是一个用于判断堆分配请求是否合理的函数, 有许多方式来触发这个函数;
选取其中最简单的方法, 使用某种手法来修改top chunk的chunksize位, 使得它小于之后要申请的chunk的大小, 注意这里与house of orange相反, 我们需要让修改后的 chunksize_nomask(size) + &chunk_head不与内存页对齐, 从而触发异常. __malloc_assert会尝试将错误信息输入到stderr
这个输入的过程的调用过程如下
1 | __malloc_assert() --(assert false)--> __fxprintf ----> vfxprintf() ----> locked_vfxprintf() ----> __vfprintf_internal() ----> _IO_file_xsputn() |
一路到最后, 函数尝试调用了_IO_file_xputsn(), 而这个函数正好是通过_IO_file_plus结构体中的vtable加上偏移计算的, 这就给了我们篡改的机会,
下面的_IO_file_jumps就是被查询的虚表, 关注在__xsputn下方0x10偏移处的__seekoff
下面是seekoff的源码, 省去不重要的信息, 发现在return之前会调用_IO_switch_to_wget_mode(fp), 这里的fp毫无疑问应该是stderr
1 | _IO_wfile_seekoff (FILE *fp, off64_t offset, int dir, int mode) |
_IO_switch_to_wget_mode, 又到了_IO_WOVERFLOW(),
1 | _IO_switch_to_wget_mode (FILE *fp) |
_IO_WOVERFLOW()的汇编, 注意 +37 位置的call指令, 只要能够控制rax, 就可以劫持控制流了(finally!)

现在需要知道rax在call之前是如何赋值的, 由于rdi始终没有变, 所以以rdi作为基准, rdx = rdi + 0xc0; rax = rdi + 0xa0 + 0xe0; rsi = 0xffffffff, 根据blog https://xz.aliyun.com/t/13016?time__1311=GqmhBKYKGIqGx0HQ1DuWxgCWv2xTDpYD#toc-5, 这里的rdi实际上是一个堆地址
有一点不一样, 就是上面blog中的_IO_WOVERFLOW()的源码没有mov esi, 0xffffffff, (笔者glibc版本2.35), 所以在此情况之下, 实际上只能向call指令的函数传一个有效的参数(rdi). 对此, 可以使用setcontext这个gadget, 因为它主要使用rdx和偏移来设置其他寄存器, 而rdx是可以被控制的
总而言之, 需要将原本的stderr的地址修改为可控的一大块数据(通过largebin attack), 然后将其中的_IO_file_jumps虚表, 改为该虚表 + 0x10 的值, 然后触发__malloc_assert
伪造_IO_FILE结构体
从上面的分析来看, 完成劫持需要制造错误的vtable偏移, 需要绕过mode, must_be_exact, was_writing的检查, 这些内容可以通过通过伪造一个假的_IO_FILE_complete结构体, 在把原本的stderr用这个假的替换, 即可满足
所以这里有必要了解一下_IO_FILE等结构体的结构
首先是_IO_FILE结构体, 内容比较多, 但主要关注于前面8个指针, 它们和绕过检查有关
中间的_IO_backup_base, 似乎一些手法可能会用得到, 但不是这里
然后是chain结构体, 用来连接其他的结构体, 比如stderr会连接stdout(上文的图中)
1 | struct _IO_FILE |
然后是_IO_FILE_complete结构体, 是_IO_FILE的加长版, 关注_wide_data指针, 和绕过检查有关系
1 | struct _IO_FILE_complete |
_IO_FILE_PLUS结构体, 在_IO_FILE基础上加入一个vtable指针(虚表指针), 虚表指针中存放的是IO相关的操作函数
其次, 注意上面源代码中的#ifdef宏定义, _IO_FILE_PLUS中的_IO_FILE也可以指的是_IO_FILE_COMPLETE结构体
1 | struct _IO_FILE_plus |
具体是那种结构, 猜测可能与使用的文件open函数有关, 但是没试过. 无论如何, 要伪造的stderr是_IO_FILE_COMPLETE + vtable 的样式
此外, 在libc中存在一个指向_IO_FILE_plus结构体的_IO_list_all指针, 通常情况下指向_IO_2_1_stderr, 然后stderr又通过chain指向stdout, stdout指向stdin
当出现了新的文件描述符, 会插入到这个链表的头部
_IO_jump_t结构体, 有许多操作函数, 但是不同的_IO_FILE_PLUS, 可能会使用不同的虚表, stderr/stdout/stdin使用的是_IO_file_jumps
1 | struct _IO_jump_t |
对于__malloc_assert的触发方法, 我们需要伪造stderr结构体, 以下是一个通用的模板(https://bbs.kanxue.com/thread-273895.htm#msg_header_h3_5)
1 | fake_io_addr=heapbase+0xb00 # 伪造的fake_IO结构体的地址 |
更加具体的模板, 来自https://xz.aliyun.com/t/13016?time__1311=GqmhBKYKGIqGx0HQ1DunFG8YwpVDpYD
1 | fake_struct = p64(0) #_IO_read_end |
在具体使用时, 需要更改fake_io_addr为伪造的fake_IO的堆的地址, _IO_save_end为要调用的函数(即call_addr), _IO_backup_base为执行函数时的rdx, 以及修改_flags(即rdi)为执行函数时的rdi
__malloc_assert举例
这里以那道著名的 house of cat 举例, 但是只关注largebin的部分, 绕过沙箱的部分忽略.
使用了__malloc_assert触发orw的方法
第一步是要先泄露出libc和heap基址, 这部分省略, 请各显神通
第二步是伪造一个_IO_FILE_PLUS结构, 用于绕过检查以及劫持虚表
第三步是通过largebin attack将stderr使用伪造的结构体替换,
第四步, 弄一个ROP或者是ORW之类的, 和二三步顺序可以互换
第五步, 想办法触发__malloc_assert, 常用的办法是修改top chunk size
模板中的call_addr修改为setcontext+61, 并在rop_addr指示的堆地址填入需要的rop链
完整exp可以看https://xz.aliyun.com/t/13016?time__1311=GqmhBKYKGIqGx0HQ1DunFG8YwpVDpYD, 这里对伪造的部分做更具体地解释
1 | ... |
有几点细节需要注意.
第一, 这套基于__malloc_assert的打法在现在更高版本的glibc中已经不复存在了, 因为__malloc_assert被删除了, 但是largebin attack的其他方法, 比如FSOP依然可以
第二, stderr结构体指针有时不在libc中, 而是在.bss段中. 出现这种情况一般是使用了setvbuf(), 而不是setbuf()或者不使用. 这是因为setvbuf()会在源文件中使用三个extern变量指针, 在链接时被ld放入.bss段; 而setbuf()使用的三个指针放在.got内作为外部链接
第三, 由于largebin attack写入的是chunk head的地址, 再加上前4个字长的large bin的信息, 所以导致将这个堆块看作一个IO_FILE_PLUS时, 它的_flag(前8字节), 以及_IO_read_ptr, _IO_read_end, _IO_read_base, _IO_write_base, _IO_write_ptr(各八个字节)实际上是难以控制的, 除非有heap overflow或者UAF之类漏洞, 但是即使这样也不会影响这种攻击方法的使用.
FSOP
一个比较古老的漏洞,但是进入“虚表偏移时代”之后FSOP的形式出现了一些不同
FSOP利用的两个部分,第一是它的调用链,第二是触发FSOP
触发IO
在伪造了相应结构之后, 想要进行FSOP, 让伪造数据被用上, 需要先进入IO流, 在高版本的glibc中, 一般有两种方式进入IO流: _IO_flush_all_lockp(), 以及house of kiwi方法, house of kiwi方法就是上面的__malloc_assert方法.
_IO_flush_all_lockp()方法
这种方法是FSOP的传统做法, _IO_flush_all_lockp()会从_IO_list_all查找IO_FILE结构体, 然后分别对每个结构体flush, 这个过程中会使用虚表中的_IO_overflow
触发这个函数又有一些办法, 但是在高版本glibc中砍得七七八八, 基本只剩下程序使用exit()退出这一种比较常见又方便利用的方法
精简代码
1 | int _IO_flush_all_lockp (int do_lock){ |
