这道题在pwn方向是做出来的队伍最多的一道题,但由于笔者之前对于高版本glibc的_IO_FILE攻击方式不甚了解,因此比赛的时候跳过了。本文就对该题进行从原理到实战的详细分析,帮助读者理解本题使用过的攻击方式。
house_of_cat
本题使用的glibc版本是2.35,是目前ubuntu 22.04上最新的glibc版本。因此本题的调试与做题环境为:Ubuntu 22.04。
本题的漏洞利用方式为house of apple,这是一种基于large bin attack的_IO_FILE攻击方式。那么首先我们就需要了解large bin attack和_IO_FILE利用这两个基础知识。
前置知识1——高版本libc的large bin attack
large bin attack从2.23版本到2.35版本,一直是一种没有被解决的利用方式,在高版本的libc中,large bin attack的具体方式与低版本区别并不大,利用原理也是相同的。不过与2.23和2.27版本不同,2.30及以上版本在_int_malloc函数中对于large bin新增了两个检查:(截图来自这里)
下面我们通过how2heap简单看一下2.35版本的large bin attack是如何实现的。
1 | Since glibc2.30, two new checks have been enforced on large bin chunk insertion |
以上就是程序的输出结果。可以看到其利用的方式非常简单,前提条件是:
- large bin中有1个chunk,unsorted bin中有一个chunk(如果被链入到large bin中需要与前面的chunk链到一个bin中),且large bin中的比unsorted bin中的大。
- 可以修改large bin中chunk的bk_nextsize指针。
当我们分配一个大chunk使得unsorted bin中的chunk被链入到large bin时,由于原先的large bin chunk比这个chunk大,所以居于其后(对large bin链入过程不清楚的读者可以先看这里),这就绕过了添加的两个检查,能够成功将原large bin chunk中的bk_nextsize->fd_nextsize修改为新链入的chunk地址,即实现了任一地址写一个堆地址。
前置知识2——_IO_FILE
在之前的文章中分析过,这里就不费笔墨了。在这篇文章中也有简要的介绍。
既然large bin attack可以实现任意地址写,如果我们将_IO_list_all的值修改为一个堆地址,那我们岂不是可以控制_IO_FILE结构体的执行流了吗?现在,我们就回到这道题本身来进行分析。
Step 1: 逆向分析
这道题的漏洞很好找,就在delete_cat这个函数中,删除操作中的free并未清空指针,因此有UAF漏洞。不过在能够操作菜单之前,我们还需要进行登录操作。这一部分的分析不难,按照函数的执行流程进行分析调试就能够获取到成功登录的字符串输入格式。最终通过login函数成功登录的字符串为:LOGIN | r00t QWB QWXFadmin\x00
在进入菜单之后,我们还需要通过某些检查。这些检查也不难通过,输入字符串为:CAT | r00t QWB QWXF\xFF$
重点就在于菜单的四种操作。添加是正常的添加操作,只不过每一次添加的chunk可写部分大小必须在0x418到0x470之间,这是属于large bin的范围,因此本题和tcache无关。
然后是编辑功能,每一次只能编辑chunk可写部分的前30个字节而不能控制所有字节。
show与edit相同,也是只能展示前30字节。
由于本题中的delete函数有UAF漏洞,因此我们只要show一个free chunk就能够轻松获取到libc和堆地址。因此进行一次large bin attack并不是什么难事。但关键在于,我们应该如何构造假的_IO_FILE结构体。注意,本题中使用了沙箱,我们不能直接调用system函数getshell,因此还需要借用setcontext函数。
Step 2: 漏洞分析
本文主要参考Nu1L师傅的wp进行分析。其使用了__malloc_assert
函数作为跳板进行漏洞利用。首先我们需要知道这个函数在何处被调用。
1 | // malloc.c line 292 |
在malloc.c中我们可以找到,这里的__assert_fail
就是__malloc_assert
,即在这里调用assert_fail
就相当于调用__malloc_assert
。而__assert_fail
是在assert
函数中被调用,因此只需要找到在malloc
函数中何处调用了assert
函数即可。但assert
函数调用的地方实在太多,我们应该选择哪一个呢?注意在_int_malloc
函数中,所有针对堆的检查错误信息打印都是使用malloc_printerr
函数而非assert
。因此我们选择_int_malloc
函数调用的sysmalloc
函数。在sysmalloc
函数中有检查是使用assert
来实现的,而在_int_malloc
函数中只有当完全确认释放的chunk无法满足申请需求且top chunk的大小也小于申请大小时才会调用sysmalloc
函数。我们首先分析一下进入sysmalloc
函数之后应该如何做才能拿到flag,至于如何调用sysmalloc
函数,则是堆块排布方面的事情了,我们在后面也会提到。
在sysmalloc
函数中,有这样一条assert
语句:
1 | // malloc.c line 2617 |
这是用来检查top chunk
的一些属性,其中注意最后一行,top chunk
必须页对齐。如果这里的top chunk
没有满足页对齐,那么就会调用__assert_fail
函数,也即__malloc_assert
函数。而在__malloc_assert
函数中,经过调试发现,漏洞利用是发生在调用__fxprintf
中而非fflush
函数。这是因为当我们执行到assert
失败时,_IO_FILE
应该已经被我们修改,而__fxprintf
作为一个需要将字符串输出到控制台的函数,必然会调出stderr
文件描述符进行输出。但这个时候只有我们自己伪造的_IO_FILE
指针,只要我们构造好假的stderr
,就有可能实现任意代码执行。
笔者仔细研究了一下本题的利用思路,发现这是典型的house of emma利用方法。(资料参考)
经过笔者多次调试跟踪,最终发现程序在__vfprintf_internal+0x280
处调用了vtable+0x38
处的函数,其第一个参数rdi
指向的是伪造的stderr
:
查看vtable类型的源码声明:
1 | struct _IO_jump_t |
可以看到,这里本意实际是想要调用结构体中偏移为0x38的成员,即_IO_xsputn_t
函数。
又找到_IO_cookie_jumps
结构体:
1 | static const struct _IO_jump_t _IO_cookie_jumps libio_vtable = { |
其中注意到有一个_IO_cookie_read
函数,我们查看一下这个函数在IDA中的汇编:
1 | .text:000000000007F7B0 ; __unwind { |
注意到这里有一个jmp rax
,实际上就是jmp [rdi+0E8h]
。而这里的rdi
就是伪造的stderr
,因此我们只需要在假stderr
后面的特定位置写入_IO_cookie_jumps+0x38
就可以保证执行到_IO_cookie_read
函数,然后在假stderr+0xE8
的位置写入正确的值就能够使得jmp rax
跳转到我们想要的地方去。不过在此之前我我们可以看到_IO_cookie_read
函数对rax
的值做了一些修改,即上述代码中的ror
指令和xor
指令。这实际上是高版本glibc新增加的一种保护措施:
1 | static ssize_t |
注意这里的PTR_DEMANGLE
函数,就是ror/xor
指令的实现,其实质是:
1 |
注意:在/sysdeps/unix/sysv/linux/x86_64/sysdep.h
文件中有4个关于PTR_DEMANGLE
函数的声明,但通过查看源码可知最有可能采用的就是上面的这个宏定义。通过源码可知第一条语句ror
循环右移的位数为11,而第二条语句xor rax, fs:30h
中的fs:30h
应该指的就是tcbhead_t.pointer_guard
这个东西。
1 | typedef struct |
这是tcbhead_t
的声明,可以看到除了pointer_guard
之外,这里面还定义有stack_guard
,合理猜测这应该是用于canary
。经过验证发现确实如此,函数开头的mov rax, fs:28h
取的就是stack_guard
的值。因此这里的fs:30h
也就是pointer_guard
的值。我们并不能读取原来的pointer_guard
,但我们能通过large bin attack
将这里的值修改为一个已知的值,这样我们就可以自行对想要执行的地址进行处理,经过_IO_cookie_read
函数右移处理后变成正确的代码地址。那么tcbhead_t
这个结构体在什么地方呢?实际上这个结构体并不在libc中,而是在紧邻libc低地址处的一块内存空间中(见下图),其与libc起始地址的偏移为-0x28c0
。但这个值是在wp中的exp出现的,如果是我们自己做题,又应该如何获得这个值呢?前面提到pointer_guard
与stack_guard
相邻。我们在程序调试的时候可以将断点下在函数开头获取stack_guard
的地方——mov rax, fs:0x28
,获得stack_guard
的值后再对内存空间进行搜索,这样就可以轻松找到tcbhead_t
结构体了。
在本题中,我们可以通过large bin attack轻松修改这里的值,由此我们就可以在fake stderr+0xE8
处写入处理后的地址值,然后就可以实现任意地址执行。由于本题开启了沙箱,因此这里容易想到跳转到一个称为pcop的gadget,由于在新版本libc中setcontext
函数中对rsp
赋值的地址不再由rdi
取值,因此需要这一个gadget将rdx
赋值,其中的rdi
附近内存是我们可控的,因此通过这个gadget地址我们就可以控制rdx
的值:
1 | .text:00000000001675B0 mov rdx, [rdi+8] |
我们可以将rdx
赋值为一个可控的内存空间地址,然后通过call
指令跳转到setcontext
函数中就可以成功实现栈迁移。
现在我们已经搞清楚了如何通过假的stderr
实现任意代码执行,但我们应该如何替换stderr
呢?前面提到,我们需要使用一次large bin attack
修改pointer_guard
的值,在这里,我们还需要再进行一次large bin attack
直接修改stderr
的值。注意到large bin
的前32个bin所保存的chunk的大小差值为0x40,即大小在0x400~0x430的chunk保存在第一个large bin
,而0x440~0x470则保存在第二个large bin
中,两个相邻的bin中保存的最小chunk的大小之差为0x40。从本题可以分配的chunk大小可知,我们一共可以进行2次large bin attack
,这两次攻击应发生在不同的bin中。
现在,我们也已经有了办法替换stderr
,但还有最后一个问题:如何才能让top chunk
缩小?根据本题的UAF漏洞不难联想,这一题应该是想要让我们通过UAF漏洞修改top chunk
的大小。具体的步骤如下:
我们需要首先分配两个相邻chunk,假设大小均为0x440,并在其高地址处分配至少一个chunk暂时防止与top chunk
合并。然后释放两个相邻chunk,释放后二者会进行合并。此时再次分配一个大小为0x430的chunk和一个0x450的chunk重新获取这两个chunk的内存空间,修改原来被释放的chunk的头部。由于我们还保存着原来chunk的指针,因此可以再一次释放这个chunk,使其与top chunk直接合并,然后继续编辑就可以成功修改top chunk的大小。
Step 3: 编写exp
为了行文逻辑流畅,这里先将exp贴出来,然后再对其中细节进行深入分析:
1 | from pwn import * |
前面的交互就不用说了,首先是释放chunk 1和4获取到libc和heap地址,并顺便使用0x400~0x430的large bin的large bin attack修改tcbhead_t
结构体中的pointer_guard
。pcop
变量就是前面提到的pcop地址,encrypted_addr
就是处理后的地址,经过_IO_cookie_read
函数处理后能够变成pcop
地址。
在payload中首先是_IO_FILE
结构体,可以使用pwntools
自带的FileStructure
类进行声明,如果需要将其转为字节可使用bytes()
函数进行处理。这里需要注意我们舍去了_IO_FILE
的前0x10字节,因为large bin attack只能够将chunk地址写到stderr
中,在可写头前面还有prev_size
和size
字段,为了保证对齐,需要舍弃_IO_FILE
结构体的前0x10字节。
在_IO_FILE
结构体后加上这个地方的堆地址和处理后的pcop地址,能够保证_IO_cookie_read
函数能够跳转到pcop中。以0x100对齐后加上setcontext
函数地址使得pcop能够调用到setcontext
函数。
在setcontext
后面紧跟SigReturnFrame
结构体,这个结构体本来是用作系统调用sysreturn
的,这里使用是因为其中rsp
和rip
的值正好能够对应上setcontext
函数中的相关指令,能够通过修改SigReturnFrame
结构体使得setcontext
将rsp
修改为我们想要栈迁移的地址,rip
修改为我们想要跳转到的地址。注意这里的SigReturnFrame
结构体舍弃了前面的0x28字节,原因与_IO_FILE
舍弃前0x10字节类似,都是为了对齐。
在此之后就是ROP链,将rsp
设置到这里,待setcontext
返回后即可在这里继续执行,后面就是常规的orw。
成功getshell。
总结
理解本题的关键在于理解函数调用链:
calloc->_int_malloc->sysmalloc->__malloc_assert->__fxprintf->...->_IO_cookie_read->pcop->setcontext->ROP