一道国赛题的多种解法
题目在这里
初步分析
首先,这道题正常情况下是必然无法运行的。使用IDA打开可以看到一个vuln函数,这个函数并没有保证栈平衡,返回时ret到了rbp的位置,且函数中出现了syscall指令。
另一个让人注意的点是题目本身提供了一个名为gadget的函数,该函数提供了两个gadget,作用分别是将rax寄存器置为0x0F和0x3B。
我们猜测出题人是想让我们利用系统调用来完成getshell。查阅系统调用表可以知道,0x3B号系统调用是sys_execve调用,而0x0F号调用是sys_rt_sigreturn调用。我们显然对前者更加熟悉,因此首先尝试利用execve调用来getshell。
ROP利用execve
第一步是/bin/sh字符串的放置,我们要将该字符串放到一个我们能知道地址的位置。对于本题来说就是栈中,接下来我们需要找到一个方法来泄露其地址。此时main函数中的两条指令引起了我们的注意:
这两条指令出现的原因通常是我们在写main时参数设为了int argc与char *argv[]。此时edi中存放着命令行参数的个数,而rsi指向一个字符串数组。回忆我们在上一篇文章 Linux Program Start Up 中提到的知识:__libc_start_main函数最后调用main函数之前进行了一系列操作来设置main函数的参数,如下图所示:
在这里,我们对rsi进行溯源查找,可以看到如下的数据流:
1 |
|
因此实际上mian函数开头处被放入栈中的rsi实际上仍然指向了一个栈中的地址。这个地址刚好被write函数泄露了出来。一般情况下,程序初始化所用的函数的栈帧大小是相同的,这意味着我们或许可以通过一个偏移来获取我们通过read函数读入的字符串的绝对地址。 (换一种说法,rsi指向命令行参数数组,该数组保存在栈中,也可以得到上述结论) 经过动态调试,read函数所使用的buf与泄露出的rsi之间的偏移是0x118。现在让我们写出第一部分的利用代码。
1 |
|
现在我们得到了我们即将输入的/bin/sh的地址,并且程序再次返回到了vuln函数的开头。注意这个vuln函数的栈帧与上一个完全相同,因此我们可以先得到字符串地址,再进行输入。 这时我们只需要设置rax,rdi,rsi,rdx寄存器,再调用syscall就大功告成了。为了做到这一点,我们需要题目提供的gadget来设置rax,0x004005a3的gadget来设置rdi,以及如下图所示的位于__libc_csu_init函数中的通用gadget来设置rsi与rdx。具体来说,我们有两种approach。
方法一
先贴出代码:
1 |
|
此方法的核心在于将设置rax所用的指定的地址放在sh字符串后面,因此我们可以简单的得知存放指向设置rax的指令的指针的地址,再将该地址送入r12即可完成调用。同时设置rbp的值,使循环只进行一次。然后填充6个pop以及一个add,最终rop调用syscall。然而这种方法虽然有效,但并不优雅。
方法二
本方法来自这里。 回想一下我们在 上一篇文章 中提到过的__libc_csu_init函数的实际作用:遍历并调用.init_array与.fini_array section中存放的函数指针并进行调用。利用类似的思想,我们是不是可以将这两个section取代为我们自己的呢?这样一来,我们只需指定函数指针数组的起始位置(r12寄存器)与函数个数(rbp寄存器),便可以以任意顺序执行我们指定的函数了。(甚至无需指定函数个数,因为我们不要求代码完全正常运行,只要getshell就可以了) 刚好,栈地址的泄露可以让我们轻易完成这一点。使用下面的代码:
1 |
|
可以看到,我们在r12中放入了一个栈地址,其中偏移其实就是pl2字符串中开头到set_rax的长度。这个循环会依次执行set_rax、pop_rdi最后到达syscall。从而getshell。 这种方法直接利用了原本程序设计者的思路,借刀杀人之法可谓巧妙。
ROP调用rt_sigreturn
这种方式由于其对信号的利用被称为SROP技术。在真正深入到这项技术中之前,我们需要了解Linux中的异常处理机制。
预备知识
所谓异常,就是程序控制流的突变,用来相应处理器状态中的某些变化。换句话说,异常提供了一种CPU与操作系统内核通讯的机制。Linux下的异常分为以下几类:
- 中断:中断可以与当前进程无关,它是处理外部硬件IO的结果。因此它是异步的。如我们打字时每敲击一个按键都造成了一次键盘中断。
- 陷阱:陷阱是有意的异常,它提供了一种从用户态进入内核态的接口。最典型的例子便是syscall指令。
- 故障(fault):这种异常我们可能见过很多次,如我们痛恨的segmentation fault,它常常会引起一个无法恢复的终止。还有一种“好”的故障,如page fault。它是页管理机制中不可或缺的一环。
- 终止:一个致命错误,终止处理程序将不会把控制权交回应用程序。
上面的异常都属于低层次的异常,他们大部分是由硬件决定的。实际上还存在一类更高级的异常,这类异常被称为信号,它允许内核和进程中断其他进程。我们可以简单的认为,硬件异常用于CPU与内核之前的交流,信号(软件异常)用于内核与进程,进程与进程之间交流。 以上是关于信号的描述性知识。我们知道,信号可能带来的是用户态与内核态的转变,这需要进行上下文切换,而上下文切换意味着我们需要把当前进程的一个状态(包括寄存器等内容)暂存起来。当信号控制权从内核切换到用户程序时,将会发生一个系统调用rt_sigreturn或者sigreturn。该系统调用会从暂存的信息中恢复原本进程的状态。SROP技术的关键就在于以下两点:
- 伪造暂存的信息,这些信息一般存放在用户栈中
- 当这些信息被放入栈中之后,我们很少有机会能修改它,因此我们还需要伪造一个从信号返回的状态。
做一个尝试
我们先来看看一个正常的signal handle流程是怎样的,示例程序如下:
1 |
|
正常情况下,我们可以使用Ctrl-C来终止一个前台进程,这是因为当我们按下这个组合按键时,CPU产生了一个中断,该中断由内核受理,进而传递了一个SIGINT信号给进程(大致如此,实际上要复杂得多)。按照默认行为,该进程将会被终止。然而在这个例子中,我们使用了一个自定义的函数来处理该信号,这造成的结果便是程序不会终止,只会打印出一个字符串Don't Ctrl-C!!!。 下面我们使用gdb调试该程序,看看上下文切换的过程究竟是如何运作的。 首先使用handle SIGINT nostop pass来告诉gdb不要拦截我们要发出的信号。然后在我们的回调函数上下断点。运行程序,然后Ctrl-C,这时我们运行到了回调函数处:
紧接着,在回调函数的调用栈中,我们发现了一个意料之外的函数__restore_rt。也许这个存在于libc中的函数是为上下文切换恢复信息的关键。
这个函数出乎意料的短,它只进行了一个调用号为15的系统调用,该调用让人感觉很眼熟,这不就是我们的国赛题目中提供的系统调用rt_sigreturn吗。我们执行到该函数,然后查看栈中的内容:
(上方的是产生信号之前的内容) 不难看到,在进行rt_sigretur系统调用之前栈中存在一个保存有上下文切换之前寄存器的值的结构,在64位下这个结构是ucontext结构体,其定义如下:
1 |
|
该结构中存有关于要返回的进程的一些状态,rt_sigreturn系统调用将会利用这些信息恢复原本的进程。因此我们可以伪造一个这样的栈结构,再直接调用rt_sigreturn就可以将寄存器(基本包括所有常用寄存器以及段寄存器,eflags)设置为我们需要的值。
实际应用
我们来根据上面的知识尝试使用SROP的方式pwn掉这道题,鉴于手动构造这样一个结构不太现实,我们使用pwntools库提供的SigreturnFrame()类来协助我们构造:
1 |
|
结合第一种方法中我们得到的sh字符串地址即可getshell。 由于笔者能力有限,更多关于SROP的操作没有呈现在这里,这里有一篇不错的文章(似乎更多操作需要深入到内核当中去) 参考: https://blog.csdn.net/github_36788573/article/details/103541178 https://m4tsuri.io/2020/04/13/linux-program-start-up/#toc-head-2 https://www.openwall.com/lists/kernel-hardening/2016/04/24/2 https://stackoverflow.com/questions/13341870/signals-and-interrupts-a-comparison/13380714 http://www.ieee-security.org/TC/SP2014/papers/FramingSignals-AReturntoPortableShellcode.pdf https://thisissecurity.stormshield.com/2015/01/03/playing-with-signals-an-overview-on-sigreturn-oriented-programming/ https://www.anquanke.com/post/id/85810