本题是一个栈溢出题,难点在于开启了PIE,因此里面的函数地址是随机的。

溢出点在这里

让我们看看提示功能的函数

看看它的汇编代码

不管条件是否成立,system的地址都会保存到这个函数的rbp-110h处,也就是当前函数的栈顶。
我们再看看这个函数

我们进去看看

如果我们输入的levels大于0,v7才有初始化,那么,如果没有初始化,它的值会是什么呢?
关键点就在这里

V7也就是rbp-110h处的数据,由于这个函数和hint函数都是在主函数里依次调用的,它们的rbp是同一个,只是在不同时刻使用而已。那么,如果我们先执行一次hint功能,再进入这个函数,那么,v7就会存储着system的地址!
现在我们的目的是进入那个有溢出的函数,看看能不能覆盖它的返回地址


这个函数是在go函数里调用的,因此它的rbp也就是go函数的rsp
进入这个函数后,栈中的布局是这样的

在answer函数中,我们的buf是在rbp – 30h处,因此我们要输入30h + 8h = 38h个字符,才能覆盖到answer的返回地址。

关键是我们不知道,返回地址该覆盖成什么,因为开启了PIE,地址是随机的。
然而,在偏移3*8 = 24字节处,却保存着system的地址,要是我们能划过这24字节,到system不就好了吗?正如welpwn题那样利用pop划过。然而,pop在这里也用不了,因为是随机地址,我们找不到。
然而,有一个例外的东西,它的地址是固定的。那就是vsyscall

Vsyscall用于系统调用,它的地址固定在0xffffffffff600000-0xffffffffff601000,vsyscall在内核中实现,无法用docker模拟。因此某些虚拟机上可能不成功。
简单地说,现代的Windows/*Unix操作系统都采用了分级保护的方式,内核代码位于R0,用户代码位于R3。许多对硬件和内核等的操作都会被包装成内核函数并提供一个接口给用户层代码调用,这个接口就是我们熟知的int 0x80/syscall+调用号模式。当我们每次调用这个接口时,为了保证数据的隔离,我们需要把当前的上下文(寄存器状态等)保存好,然后切换到内核态运行内核函数,然后将内核函数返回的结果放置到对应的寄存器和内存中,再恢复上下文,切换到用户模式。这一过程需要耗费一定的性能。对于某些系统调用,如gettimeofday来说,由于他们经常被调用,如果每次被调用都要这么来回折腾一遍,开销就会变成一个累赘。因此系统把几个常用的无参内核调用从内核中映射到用户空间中,这就是vsyscall(引文来自https://bbs.ichunqiu.com/thread-43627-1-1.html)
我们要利用的就是最后的那个retn,因为它会从栈顶弹出一个元素,就相当于esp下移了一个单位。我们把answer的返回地址处以及栈下面2个都覆盖成vsyscall的地址0xffffffffff600000,那么,栈变成这样

这样,三次的vsyscall,相当于从这片区域滑到了Go函数的rsp-110处,这样,接下来就会执行system了。
然而,system函数需要一个参数,并且x64使用寄存器传参,那么我们就不能用system了。我们考虑用没有参数的函数,我们可以用one-gadget工具查找libc中可用的函数。


我们如何让rsp-110h存储着gadget的地址呢,我们只得到了它的静态地址。
我们看到那个go函数里.

假如,我们输入的数字为n,也就是
[rsp-110h] = system_addr +n
假如[rsp-110h] 是gadget的加载地址,那么n = gadget_addr - system_addr,我们知道,两个函数之间的偏移是固定的,不管是静态分析,还是动态载入时,它们相对地址是不变的。因此,我们可以用它们在libc中的静态地址,来算出n,我们得到的gadget在libc.so为0x4526a
,那么n = 0x4526a – libc.sym[‘system’]
我们需要答对前99题,最后一题,我们再发送payload

于是,我们最终写出如下exp脚本
1 | #coding:utf8 |