我的第一个真实环境二进制漏洞复现:CVE-2018-1160利用
0x00 关于本文
其实我是想复现CVE-2022-23121的,但二进制水平还不够,所以决定先踩着前人的脚步复现相同产品的另一个漏洞CVE-2018-1160,多学点东西。
在这次复现过程中我得到了@povcfe的许多帮助,在此表示感谢。
文中涉及的代码已放在Github。
0x01 环境搭建
这篇文章介绍了如何搭建环境,但我的系统不是Ubuntu18.04,其运行的新版本Glibc会导致漏洞利用更加困难,因此我需要切换回老的Glibc。
搭建一个Ubuntu18.04的虚拟机并且在上面把各种调试工具装好是最朴素的做法,但这样太麻烦,并且要是下次我需要一个Ubuntu16或者别的版本那又得重头再来。因此我的办法是用Docker启一个Ubuntu18.04的容器,并将它的548端口映射到宿主机上,在这个容器中编译Netatalk并运行。由于容器本质上是受到严格限制的进程而不是虚拟机,我可以直接用宿主机上的调试工具attach到容器内部的进程来调试而不需要在容器内安装这些调试工具。
说了这么多,其实启动这个容器就一行命令的事
docker run -p 548:548 -i -t ubuntu:18.04 /bin/bash后续的东西按着本段开头的那个文章搭就好了,当然pwntools和pwndbg这些搞pwn必备的东西也是要在宿主机上安装的。
0x02 Netatalk是怎么跑起来的&如何调试它
Netatalk的源码的调用关系在这里写的有。简单地说,它对于每一个新的连接都用dsi_tcp_open fork出一个子进程并接收参数,并返回到dsi_getsession中处理连接建立和关闭的过程,而与这个连接后续的交互过程都在afp_over_dsi中实现。基于这个特性,我们可以通过在gdb中追踪子进程来调试Netatalk对连接的处理。
我们可以通过gdb一行命令实现这件事。其中pgrep用于找到它的pid,而设置follow-fork-mode child可以让gdb选择跟踪子进程,directory指令可以设定源码的路径,这样我们在调试的时候可以看C代码而不用啃太多汇编。
gdb -q -p `pgrep -n afp` --ex "set follow-fork-mode child" --ex "directory /netatalk-3.1.11"
0x03 通信协议
真实的漏洞利用和CTF的菜单题不一样,CTF菜单题怎么交互每个功能干什么都写的清清楚楚的,但是真实环境却更加复杂。因此想要成功复现这个漏洞我们需要首先搞清楚它的协议是什么样的。
Netatalk处理被DSI协议封装过的AFP协议,DSI协议在维基百科上有详细的介绍。
想要观察程序是如何对该协议处理的可以对dsi_stream_receive下断点进行调试,下图是我通过调试源码得到的我们发送的数据与数据如何被存储在dsi结构体的关系,这些和维基百科上对协议的字段理解完全一致。
/* parse options */
while (i < dsi->cmdlen) {
switch (dsi->commands[i++]) {
case DSIOPT_ATTNQUANT:
memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]);
dsi->attn_quantum = ntohl(dsi->attn_quantum);
case DSIOPT_SERVQUANT: /* just ignore these */
default:
i += dsi->commands[i] + 1; /* forward past length tag + length */
break;
}
}
对照上图,不难得知dsi->commands的内容完全由发来的包控制,因此memcpy的内容和拷贝大小可由攻击者操控,而attn_quantum只是一个小小的unsigned int,所以攻击者可以在这里越界写入dsi结构体上的其它字段。
为了验证这个越界写漏洞,我们首先用上面写的命令attach到程序,然后用如下代码发送一个实现一个长达0xff的写入。之所以是0xff是因为dsi->commands是一个uint8_t类型的数组,每个元素最大是0xff,因此我们覆盖的长度最多是255。
from pwn import * def create_afp_cover_attnquantum(content): cmd = b'\x01'+ p8(len(content))+content dsi = b'\x00\x04\x00\x01' dsi += p32(0) dsi += p32(len(cmd),endian='big',) dsi += p32(0) dsi += cmd return dsi io = remote("127.0.0.1",548) io.send(create_afp_cover_attnquantum(cyclic(0xff))) io.recv()gdb里直接continue,程序崩溃的位置在35行,但实际上这是gdb的错误判断,真正崩溃的位置是在39行,程序尝试从dsi->commands[i]处读取内存,但是dsi->commands指针已被覆盖,因此这一行尝试读取的内存是非法的,所以产生了崩溃。
想要看查看具体的覆盖情况,我们可以先用set max-value-size unlimited设置可查看的value大小无上限,接着用p *dsi查看dsi结构体。如图,attn_quantum到data都被覆盖了。而通过cyclic -l 0x61616165还可以知道对于commands指针的覆盖是从第16的字节开始的。
0x05 从越界写到PIE绕过
这个越界写有助于我们攻破PIE机制。
PIE机制是一种针对代码段进行随机化的ASLR机制,在这个机制开启后程序每次加载时各种库的基地址都是随机的,这样攻击者即使能劫持控制流了也不知道该往哪儿跳。但这个机制只在程序加载的时候进行随机化,而Fork出来的进程并不会再来一次随机化,这导致了这些子进程中各种库的基址都是一样的,而配合上面的漏洞,攻击者可以一点点地获取Glibc地址。
在上面的例子中,我们通过构造超长的数据,完全覆盖了commands指针,让它指向的内存非法,造成程序崩溃。但事实上,我们不必将其完全覆盖,而只需要覆盖部分字节,通过观察程序是否崩溃而推测我们覆盖的字节是否与其原有的字节相同,最终还原出commands指针原本指向的地址。经过实际测试,最低的几个字节被覆盖了并不会引起程序崩溃,因为它依然处于可写的范围内,但最高的三个字节必须要正确,否则会崩溃。
举个例子,假定commands是0x12345678abcd,如果将其覆盖为0x123456000000不会造成其崩溃,但是覆盖成0x123457000000就会造成崩溃(收不到返回值),那么攻击者通过这个差异就知道commands的第三个字节是0x56,而以此类推,前三个字节都可以逐位被获取。
那commands的地址和Glibc的基地址有什么关系呢?commands是calloc(server_quantum)申请在堆空间的,而server_quantum是个很大的值,因此会被分配至Glibc所在的空间中,因此可通过commands的位置退出Glibc的基址。而经过多次测试,commands的前两字节和Glibc基址的前两字节相同,第三字节+1和Glibc基址的第三个字节相同,所以通过获取commands的前三个字节,我们就获得了Glibc基址的前三个字节。
在PIE基址下,库的后三位(位不是字节,两个个位构成1字节)不变,因此在这个情况下,Glibc的地址只有三位是未知的了。
0x06 从越界写到任意写
如果只是读读commands,那这个漏洞就没有那么大的价值了。好当连接建立后,dsi_stream_receive会调用dsi_stream_read(dsi, dsi->commands, dsi->cmdlen)对dsi->commands所在的位置进行写入。经过测试,dsi->commands设置位目标地址-2的时候,可将包中的commands部分直接写进来,并且commands的长度可以上万,因此我们有了一个针对任意地址写入任意长度任意内容的机会。
0x07 freehook控制流劫持
在任意写漏洞的利用过程中,最主流的方式就是篡改某个函数指针,让程序使用该函数指针时控制流被劫持。afpd程序本身有很多的函数指针,但我们不知道afpd程序的基址,因此还是从Glibc上下手,劫持Glibc里的函数指针。
针对Glibc中函数指针的劫持有一个劫持方法就是控制__free_hook。__free_hook在free()函数执行之前执行,据说通过它可以更好地自定义内存的分配过程,但显而易见大家都用__free_hook捣乱,因此新版本里__free_hook指针就被剔除掉了,这也就是为什么我们要复现这个漏洞得用带有更老Glibc的系统。
写到__free_hook是容易的,但怎么触发free函数呢?有很多种办法可以触发, 我采取的策略是将dsi_command设为1,这样afp_over_dsi函数就会根据它调用afp_dsi_close,然后触发其中的free函数
switch(cmd) { case DSIFUNC_CLOSE: //1 LOG(log_debug, logtype_afpd, "DSI: close session request"); afp_dsi_close(obj); LOG(log_note, logtype_afpd, "done"); exit(0);
这个覆盖和触发可以用一个包完成,具体代码放Github上所以不贴出来了。
0x08 从控制流劫持到任意函数调用
很棒,迄今为止我们攻破了ASLR能得到Glibc的大部分基地址,而且还能劫持控制流,看起来离为所欲为已经不远了。然而在传统的栈溢出漏洞中,我们能通过布置栈地址在不同Gadget里反复横跳,但这次我们控制不了栈空间,该怎么样才能执行我们想要的代码呢?
根据复现网上的解法,我知道了一条非常妙的利用链,通过多次跳转然后用setcontext实现对各个寄存器的操控,实现任意函数调用,下面我来一点一点地介绍这条链是怎么成的。
首先我们把freehook的值设为__libc_dlopen_mode+56,这里有两条代码,干的事情相当于是把rip+0x28a269(也就是_dl_open_hook)处的值作为一个指向函数指针的指针,再跳到这个函数指针指向的函数。
之所以要这么折腾一遭,其实就是想控制rax的值,就这么回事。想要这么做,我们需要填充大量的数据,从__free_hook覆盖到_dl_open_hook,然后再写入两个值,一个是_dl_open_hook+8,另一个是fgetpos64+207。这样在执行的时候,首先rax变成_dl_open_hook+8,接着再对rax的值指向的地址接引用,最后跳到fgetpos64+207
在fgetpos64+207,首先把rax的值赋值给rdi,接着又是一个和上面类似的跳转。看到这儿应该就明白我们的这个链和ROP链的相似之处了,ROP链利用预先控制的栈数据和pop/ret在多个Gadget中跳转,我们的这个则是通过预先布置的内存和函数指针在多个Gadget中跳转。之所以在这里再转一次,是因为我们希望通过rax寄存器控制rdi寄存器。确切地说,当前rdi寄存器的值是_dl_open_hook+8,有什么用我们很快会介绍
我们故技重施,让代码跳转到setcontext+53
我们故技重施,让代码跳转到setcontext+53
看到这里或许就能猜到刚刚我们苦心孤诣去操纵rdi是为了为什么,在这里代码不断地从rdi指向的内存的固定偏移处取内存来设置寄存器,因此如果我们能够控制rdi偏移处的内存为我们所控制,我们就能利用操控几乎所有的寄存器,并且rsp我们都能操纵,还能return,我们可以利用它调用一起函数了。
SROP攻击就利用setcontext函数的这个特性伪造Signal return frame实现任意函数调用,虽然我们这个是手动调用setcontext而不是依赖于signal触发的setcontext,但上图的这个结构体是通用的。pwntools工具集成了伪造这个结构体的功能,只需要对齐结构体,就可以用它调用任意函数。
0x08 可我想要shellcode
至此我们可以利用它调用任意函数了,常规的做法就是直接system命令反弹shell,但我就是想要shellcode,因此还要再花一番周折才行。
在古典快乐黑客时代,攻击者只需要把shellcode布置到栈上然后JMP ESP就可以,但是随着NX/DEP机制的提出,这些内存都不再是可执行的。因此我们需要调用mprotect函数修改地址的权限为rwx。在这里必须得提一下,mprotect传入参数的时候,地址必须要是页对齐的,因此需要对目标地址进行& 0xfffffffffffff000,让其末三位为0。
而在mprotect函数执行完毕后会ret,ret指令从本质上讲就是POP RIP,因此还要让栈上的第一个值指向shellcode,而rsp可以操控,所以让rsp指向的地址的值是shellcode的地址即可(这句话有点绕,用类似C语言的表示方法就是 *rsp=&shellcode)。
0x09 未解之谜之为什么Meterpreter失败
我很想搞一个Meterpreter的shellcode,一执行就有Meterpreter的会话那可比反弹shell要酷多了。但是我尝试了用payload/linux/x64/meterpreter/reverse_tcp这个shellcode,调试了一下发现它原有的shellcode(stager)能正常执行,但从服务器那里接收的shellcode在某一步执行会出错,由于服务端接收的shellcode过于巨大,实在没有精力调试,因此就此打住。
最后我选择妥协,用msf生成了反弹shell的shellcode,能够正常执行。
0x09 感想
1.这条利用链很不错,只要能任意地址写任意长度的利用场景,都适合这个链。
2. 真实环境和CTF菜单题不一样,需要深入理解协议,这是个需要花时间的事情。
3. Docker调试不同环境的用户态PWN题真的很方便
评论
发表评论