htib2017 pwn 之 1000levels
前言
最近在安全客上看到一篇文章PIE保护详解和常用bypass手段,里面提到了3种绕过PIE保护机制的方式:partial write、泄露地址和vdso/vsyscall。由于之前对vdso/vsyscall机制了解的不多,于是花了点时间动手实践,以下是对题目1000levels的简单分析。
1000levels
程序分析
该程序提供的功能如下。
1 | ./1000levels |
利用IDA加载程序,通过分析,程序的功能和逻辑很简单,漏洞也很明显:在level()函数中存在一个栈溢出漏洞。
1 | .text:0000000000000ED4 lea rdi, aQuestionDDAnsw ; "Question: %d * %d = ? Answer:" |
查看一下程序开启的保护措施,可以看到启用了NX和PIE。因此可以通过栈溢出来覆盖返回地址,但问题是用什么来覆盖返回地址。
1 | checksec |
在hint()函数中,将system()函数的地址保存在了栈上,但是没办法进行泄露,在程序的其他部分也没发现存在信息泄露的可能。
vsyscall/vdso
vsyscall/vdso是用于加速某些系统调用的两种机制。由于在进行系统调用时,操作系统需要在用户态和内核态间进行切换,传统的int 0x80/iret中断有点慢,Intel和AMD分别实现了sysenter/sysexit和syscall/sysret,即所谓的快速系统调用指令,使用它们更快,但同时也带了兼容性的问题。于是Linux实现了vsyscall,程序统一调用vsyscall,具体的选择由内核来决定。
vsyscall用来执行特定的系统调用,减少系统调用的开销。某些系统调用并不会向内核提交参数,而仅仅是向内核请求某个数据,比如gettimeofday(),内核在处理这部分系统调用时可以把系统当前时间写在一个固定的位置,然后通过mmap()映射到用户空间,应用程序直接从该位置读取即可。(内核与用户态程序之间通过mmap()进行数据交换)。
但是由于vsyscall采用固定地址映射的方式,存在一定的安全隐患,这一方式被vdso所改进,其随机映射在一定程度上缓解了安全威胁。但考虑到兼容性问题(针对一些比较老的应用程序),vsyscall和vdso这两种方式可能会同时存在。
vdso全称为Virtual Dynamic Shared Object,可以将其看成是一个虚拟的so文件,由内核提供,但这个so文件不在磁盘上,而是在内核里。内核将包含某个.so的内存页在程序启动时映射到其内存空间,对应的程序就可以当普通的.so来使用其中的函数。比如syscall()函数就是在linux-vdso.so.1里面,但是磁盘上并没有对应的文件。
vsyscall和vdso的对比如下:
vsyscall方式分配的内存较小,只允许4个系统调用,同时vsyscall页面是静态分配的,地址是固定的;vdso提供与vsyscall相同的功能,同时解决了其局限。vdso是glibc库提供的功能,其页面是动态分配的,地址是随机的,可以提供超过4个系统调用。
在gdb中将vsyscall所在页面的内容dump下来,在IDA中进行查看如下。
1 | seg000:0000000000000000 seg000 segment byte public 'CODE' use64 |
可以看到其中包含3个系统调用。
1 |
前面提到过,vsyscall页面的地址是固定的,这意味着在这个区域有3个可用的gadgets。
当直接调用
vsyscall中的syscall时,会提示段错误,因为vsyscall执行时会检查是否从函数开头开始执行,所以可以直接利用的地址是从vsyscall起始偏移为0x0、0x400和0x800的地址。
利用思路
在调用hint()函数时,将system()函数的地址保存在了栈上。而在调用go()函数时,system()函数的地址仍然在栈中,因此可通过控制函数流程main()->hint()->main()->go()->level(),结合vsyscall中的gadgets,实现将控制流劫持到栈上保存的system()函数地址处。
在执行main()->hint()->main()->go()->level()时,栈帧的变化如下。

由上图可知,在执行go()函数前,system()函数的地址仍在栈上。但是在go()函数内,其局部变量v5/v6所在的内存空间地址与保存system_ptr的地址相同,因此需要确保该地址出的内容不能被覆盖。之后,go()函数调用level()函数,由于溢出发生在level()函数中,通过控制栈布局并覆盖返回地址,可劫持控制流,从而执行system()函数。
大体的思路如上,但其中还有一些细节需要处理:
如何确保
system_ptr不被覆盖? 由下面的代码可知,保证v2<=0和v3=0即可。1
2
3
4
5
6
7if ( v2 > 0 )
v5 = v2;
else
puts("Coward");
puts("Any more?");
v3 = read_num();
v6 = v5 + v3;将控制流劫持到
system()函数时,无法控制其参数。system()函数的第一个参数保存在$rdi寄存器中,但在执行strtol()函数后,$rdi寄存器的内容会改变,暂时没有其他方式来控制$rdi寄存器的内容。
在这种情况下,one_gadget就派上用场了。one_gadget是glibc里调用execve('/bin/sh', NULL, NULL)的一段非常有用的gadget,在能够控制eip/rip的时候,用one_gadget来做实现RCE非常方便。利用工具one_gadget在libc中进行查找,如下。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16one_gadget ./libc.so
0x45216 execve("/bin/sh", rsp+0x30, environ)
constraints:
rax == NULL
0x4526a execve("/bin/sh", rsp+0x30, environ)
constraints:
[rsp+0x30] == NULL
0xf0274 execve("/bin/sh", rsp+0x50, environ)
constraints:
[rsp+0x50] == NULL
0xf1117 execve("/bin/sh", rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL现在栈上保存的是
system()函数的地址system_ptr,由于system和one_gadget在libc中的偏移是固定的,因此只需要对system_ptr进行加/减操作,即可计算出one_gadget的运行时地址,而程序正好提供了这个功能。1
2
3
4
5
6
7if ( v2 > 0 )
v5 = v2;
else
puts("Coward");
puts("Any more?");
v3 = read_num();
v6 = v5 + v3;由于保存变量
v5的地址与保存system_ptr的地址相同,因此只需要控制变量v3的值即可。由于计算得到的
one_gadget的地址(也就是变量v6的值)大于1000,level()函数会递归调用多次,因此需要答对所有题目,然后在最后一次进行溢出。1
2
3
4
5
6
7
8
9
10
11
12if ( v6 <= 999 )
{
v7 = v6;
}
else
{
puts("More levels than before!");
v7 = 1000LL;
}
puts("Let's go!'");
v4 = time(0LL);
if ( (unsigned int)level(v7) != 0 )
完整的利用代码如下。
1 | from pwn import * |
- 由于利用官方提供的
libc库无法在本地运行,所以使用了本地的libc库进行测试;- 跳到
vsyscall开始处(即偏移0x0)时会报错,于是采用偏移0x400处的地址进行跳转。
相关链接
- PIE保护详解和常用bypass手段
- HITB CTF 2017 Pwn题研究
- VDSO与vsyscall
- OneGadget