Linux x86和x64区别
首先是内存地址的范围由32位变成了64位。但是可以使用的内存地址不能大于
0x00007fffffffffff
,否则就会抛出异常。其次是函数参数的传递方式发生了改变,x86中参数都是保存在栈上,但在x64中的前六个参数依次保存在RDI, RSI, RDX, RCX, R8和R9中,如果还有更多的参数的话才会保存在栈上。
X86
level1 - 栈上执行shellcode
level1主要演示32位程序中最基本的栈溢出利用,可直接在栈上写shellcode并执行。
用下面命令编译,-m32
参数指定编译为32位程序;-fno-stack-protector
参数指定不开启堆栈溢出保护,即不生成 canary;-z execstack
参数指定允许栈执行,即不开启NX。
1 | gcc -m32 -fno-stack-protector -z execstack -o level1 level1.c |
关闭ALSR,先进root权限,然后执行命令
1 | echo 0 > /proc/sys/kernel/randomize_va_space |
查看是否关闭成功,以及程序情况,发现已经关闭了,但是还是显示PIE打开,后续看看能不能溢出再说。
采用pattern_offset
获取偏移量,现在知道了偏移量,剩下的就是构造shellcode以及控制PC跳转到shellcode地址上。
shellcode构造直接用,pwntools的asm(shellcraft.sh())来获得。
下面获取写入的shellcode地址。由于ASLR等都关掉,因此现在获取的地址就不会变了。采用gdb查看内存的方式x/10s $esp-144
(144是140加上四字节的ret)
但是如果这样利用该地址其实运行会出错。查网上的博客发现:
对初学者来说这个shellcode地址的位置其实是一个坑。因为正常的思维是使用gdb调试目标程序,然后查看内存来确定shellcode的位置。但当你真的执行exp的时候你会发现shellcode压根就不在这个地址上!这是为什么呢?原因是gdb的调试环境会影响buf在内存中的位置,虽然我们关闭了ASLR,但这只能保证buf的地址在gdb的调试环境中不变,但当我们直接执行./level1的时候,buf的位置会固定在别的地址上。怎么解决这个问题呢?
最简单的方法就是开启core dump这个功能。当程序运行的过程中异常终止或崩溃,操作系统会将程序当时的内存状态记录下来,保存在一个文件中,这种行为就叫做Core Dump。可以认为是内存快照,除了内存信息外,还有一些寄存器信息,内存管理信息,其他处理器和操作系统状态和信息被保存下来。用下面这两条命令开启该功能:
1 | ulimit -c unlimited |
开启之后,当出现内存错误的时候,系统会生成一个core dump文件在tmp目录下。然后我们再用gdb查看这个core文件就可以获取到buf真正的地址了。
得到shellcode真正地址是0xffffd150
payload
果然出错了,还是之前查看程序的时候发现PIE和relro保护都有,把作者在github上的程序重新跑一下前面的步骤发现可行了
远程部署
除了本地调试,还有远程部署的方式,如下,将题目绑定到指定端口上:
1 | socat tcp-l:10001,fork exec:./level1 |
payload除了将p = process(“./level1”)改为p = remote(“127.0.0.1”, 10001)外,ret的地址还会发生改变。解决方法还是采用生成core dump的方案,然后用gdb调试core文件获取返回地址
level2 - ret2libc绕过NX
还是首先看一下这个程序情况,发现只开了NX保护
这样就不能和前面一样将shellcode写到栈上去,我们可以看一下,level1的stack是rwx的,但是level2的stack却是rw的
1 | cat /proc/[pid]/maps |
既然开启了NX,那一般是利用ROP绕过,这里用的是ret2libc,因为程序level2调用了libc.so,并且libc.so里保存了大量可利用的函数如system()和/bin/sh,我们如果可以让程序执行system(“/bin/sh”)的话,也可以获取到shell。
所以现在重要的是获取system和/bin/sh的地址。因为关掉了ALSR,所以system函数在内存中地址不会变化,并且libc.so中也包含”/bin/sh”这个字符串,并且这个字符串的地址也是固定的。
在main下断点然后运行,system地址和bin/sh地址都全出来了,溢出偏移量和之前一样140
payload
这里就不写了,很简单,和之前ret2libc差不多
level2 - ROP绕过NX和ASLR
就在上一题的基础上,不过需要打开ALSR,如果你通过sudo cat /proc/[pid]/maps或者ldd查看,你会发现level2的libc.so地址每次都是变化的:
所以这次如何利用呢?——思路是:先泄漏出libc.so某些函数在内存中的地址,再利用泄漏出的函数地址根据偏移量计算出system()函数和/bin/sh字符串在内存中的地址,最后执行我们的ret2libc的shellcode。既然栈、libc、堆的地址都是随机的,我们怎么才能泄露出libc.so的地址呢?方法还是有的,因为程序本身在内存中的地址并不是随机的,如图所示,Linux内存随机化分布图:
所以我们只要把返回值设置到程序本身就可执行我们期望的指令了。
首先我们利用objdump来查看可以利用的plt函数和函数对应的got表:
我们可以通过write@plt ()
函数把write()
函数在内存中的地址也就是write.got
给打印出来。
既然write()
函数实现是在libc.so
当中,那我们调用的write@plt()
函数为什么也能实现write()
功能呢? 这是因为linux采用了延时绑定技术,当我们调用write@plit()
的时候,系统会将真正的write()
函数地址link到got表的write.got
中,然后write@plit()
会根据write.got
跳转到真正的write()
函数上去。
因为system()
函数和write()
在libc.so
中的相对地址是不变的,所以如果我们得到了write()的地址并且拥有目标服务器上的libc.so
就可以计算出system()
在内存中的地址了。
然后我们再将pc指针return回vulnerable_function()
函数,就可以进行ret2libc溢出攻击,并且这一次我们知道了system()
在内存中的地址,就可以调用system()
函数来获取我们的shell了。
使用ldd命令可以查看目标程序调用的so库。随后我们把libc.so
拷贝到当前目录,因为我们的exp需要这个so文件来计算相对地址:
除了用ldd命令查看libc.so库,还可以直接用pwntools库的elf.libc来获取libc.so库:
1 | from pwn import * |
payload
说一下write函数原型
1 | ssize_t write(int fd,const void*buf,size_t count); |
所以首先填充‘a’
造成溢出,覆盖到返回地址,返回地址填上write函数的plt地址来调用write函数,之后跟上vulnerable_function函数地址(其实设置成main也行,我们要将程序程序重新执行一遍,再次利用输入点来进构造rop)p32(1)+p32(write_addr)+p32(4)
是在设置write函数的参数,对应函数原型看一下,32位程序是4位,所以这边写的4,对应的64位程序是8位
level2 - 在不获取目标libc.so的情况下进行ROP攻击
如果我们在获取不到目标机器上的libc.so情况下,应该如何做呢?这时候就需要通过memory leak(内存泄露)来搜索内存找到system()的地址。
这里采用pwntools提供的DynELF模块来进行内存搜索。首先我们需要实现一个leak(address)函数,通过这个函数可以获取到某个地址上最少1 byte的数据。拿上一篇中的level2程序举例。leak函数应该是这样实现的:
1 | def leak(address): |
随后将这个函数作为参数再调用d = DynELF(leak, elf=ELF(‘./level2’))
就可以对DynELF模块进行初始化了。然后可以通过调用system_addr = d.lookup(‘system’, ‘libc’)
来得到libc.so中system()在内存中的地址。
要注意的是,通过DynELF模块只能获取到system()在内存中的地址,但无法获取字符串“/bin/sh”在内存中的地址。所以我们在payload中需要调用read()将“/bin/sh”这字符串写入到程序的.bss段中。.bss段是用来保存全局变量的值的,地址固定,并且可以读可写。通过readelf -S level2
这个命令就可以获取到bss段的地址了,或者IDA中直接ctrl+s就可以获取了。
当然,可以在pwntools中直接调用elf.bss()获取.bss段地址:
1 | elf = ELF("./level2") |
因为我们在执行完read()之后要接着调用system(“/bin/sh”),并且read()这个函数的参数有三个,所以我们需要一个pop pop pop ret的gadget用来保证栈平衡。这里我们用ROPgadget来寻找:
注意用python3会出现对DynELF模块不兼容的问题, python3和python2的最大区别(pwn中), 是bytes和str类型不共通了, 所以原本python2能跑的脚本, 在python3得考虑类型转换问题, DynELF中报错也是因为这个
1 | sudo gedit /usr/local/lib/python3.8/dist-packages/pwnlib/dynelf.py |
x64
level 3 - 通过 ROP 绕过 DEP 和 ASLR 防护
程序代码如下:
查看文件基本情况,发现是64位动态链接的文件,之开启了NX保护
我们用GDB调试一下,输入大量字符串,程序终止在vulnerable_function()函数处
奇怪的事情发生了,PC指针并没有指向类似于0x41414141那样地址,而是停在了vulnerable_function()函数中。这是为什么呢?原因就是我们之前提到过的程序使用的内存地址不能大于0x00007fffffffffff,否则会抛出异常。但是,虽然PC不能跳转到那个地址,我们依然可以通过栈来计算出溢出点。因为ret相当于“pop rip”指令,所以我们只要看一下栈顶的数值就能知道PC跳转的地址了。
payload
其实payload也很简单,和之前一样,因程序中本来就存在一个callsystem()函数,其会直接调用system(“/bin/sh”),那就简单多了。
1 | payload = "A" * 136 + p64(callsystem_addr) |
level 4 - 使用ROPgadget寻找gadget
x64中前六个参数依次保存在RDI,RSI,RDX,RCX,R8和 R9寄存器里,如果还有更多的参数的话才会保存在栈上。所以我们需要寻找一些类似于pop rdi; ret的这种gadget。如果是简单的gadgets,我们可以通过objdump来查找。但当我们打算寻找一些复杂的gadgets的时候,还是借助于一些查找gadgets的工具比较方便。比较有名的工具有:
ROPEME: https://github.com/packz/ropeme
Ropper: https://github.com/sashs/Ropper
ROPgadget: https://github.com/JonathanSa…
程序代码如下,看到程序在一开始运行时调用systemaddr()函数,该函数会从本程序用到的libc.so.6中获取其中的system()函数地址并打印出来:
程序文件情况如下,只开了NX保护
因为我们知道了溢出偏移量和system()函数的地址,剩下的就是通过寄存器给system()函数传参了,而在64位中传参的前六个参数是通过寄存器来实现的,而且system()只接受一个参数,因此我们需要找到一条pop rdi;ret的Gadget来帮助我们实现,这里我们用的是ROPgadget工具帮我们查找:
payload
先填充栈空间,到达 rip 上一个内存空间。覆写为gadget地址,再接着是/bin/sh
内存地址,这样就可以将/bin/sh
存入到 rdi 寄存器。
level5 - 通用gadgets
因为程序在编译过程中会加入一些通用函数用来进行初始化操作(比如加载libc.so的初始化函数),所以虽然很多程序的源码不同,但是初始化的过程是相同的,因此针对这些初始化函数,我们可以提取一些通用的gadgets加以使用,从而达到我们想要达到的效果。
level5.c代码如下,相比于level3和level4,去掉了提供system()或其地址的辅助函数:
这道题思路和level2-ROP绕过NX和ALSR一样,但问题在于write()的参数应该如何传递,因为x64下前6个参数不是保存在栈中,而是通过寄存器传值。我们使用ROPgadget并没有找到类似于pop rdi, ret,pop rsi, ret这样的gadgets。那应该怎么办呢?其实在x64下有一些万能的gadgets可以利用。比如说我们用objdump -d ./level5
观察一下__libc_csu_init()这个函数。一般来说,只要程序调用了libc.so,程序都会有这个函数用来对libc进行初始化操作。
这个函数里先执行400606再执行4005f0有以下赋值过程
1 | RDI,RSI,RDX |
只要我们控制rbx的值为 0 ,精心构造栈上传入上述寄存器的值,就可以实现控制 pc ,调用我们想要的函数。
为什么需要控制 rbx 的值为0?
执行完 call qword ptr [r12+rbx*8] 之后,程序会对rbx+=1,然后对比 rbp 和 rbx 的值,如果相等就会继续向下执行并 ret 到我们想要继续执行的地址。所以为了让 rbp 和 rbx 的值相等,我们可以将 rbp 的值设置为1,因为之前已经将 rbx 的值设置为0了。
我们先构造一个payload1输出write在got表中的地址
为什么使用的是 write.got 而不是 write.plt?
write.plt 相当于 call write。执行了两个动作,将指针跳转到 write 真实地址;将返回地址压栈。
write.got 仅将指针跳转到 write 真实地址。
整个流程如下:
由于利用到泄露函数地址和向.bss段写内容的功能,需要先获取write()和read()函数的GOT地址;
payload1先填充溢出偏移量的字符、然后根据gadget1来设置对应寄存器的值、再调用gadget2、然后填充字符至gadget1的ret指令处、最后调用输入的返回地址即main处让程序继续执行下去;这里注意两个偏移量,第一个136是程序本身溢出到ret的偏移量,而第二个56则是gadget2跑完之后还要继续往下跑到gadget1的ret中去,这中间需要填充56个字节;
当我们exp在收到write()在内存中的地址后,就可以计算出system()在内存中的地址了。接着构造payload2,利用read()将system()或execve()的地址以及“/bin/sh”读入到.bss段内存中;
最后我们构造payload3,调用system()函数执行“/bin/sh”。注意,system()的地址保存在了.bss段首地址上,“/bin/sh”的地址保存在了.bss段首地址+8字节上。