0%

蒸米ROP

Linux x86和x64区别

  1. 首先是内存地址的范围由32位变成了64位。但是可以使用的内存地址不能大于0x00007fffffffffff,否则就会抛出异常。

  2. 其次是函数参数的传递方式发生了改变,x86中参数都是保存在栈上,但在x64中的前六个参数依次保存在RDI, RSI, RDX, RCX, R8和R9中,如果还有更多的参数的话才会保存在栈上。

X86

level1 - 栈上执行shellcode

level1主要演示32位程序中最基本的栈溢出利用,可直接在栈上写shellcode并执行。

image-20220411111305626

用下面命令编译,-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打开,后续看看能不能溢出再说。

image-20220411112803443

采用pattern_offset获取偏移量,现在知道了偏移量,剩下的就是构造shellcode以及控制PC跳转到shellcode地址上。

image-20220411140147029

shellcode构造直接用,pwntools的asm(shellcraft.sh())来获得。

下面获取写入的shellcode地址。由于ASLR等都关掉,因此现在获取的地址就不会变了。采用gdb查看内存的方式x/10s $esp-144(144是140加上四字节的ret)

image-20220411141134398

但是如果这样利用该地址其实运行会出错。查网上的博客发现:

对初学者来说这个shellcode地址的位置其实是一个坑。因为正常的思维是使用gdb调试目标程序,然后查看内存来确定shellcode的位置。但当你真的执行exp的时候你会发现shellcode压根就不在这个地址上!这是为什么呢?原因是gdb的调试环境会影响buf在内存中的位置,虽然我们关闭了ASLR,但这只能保证buf的地址在gdb的调试环境中不变,但当我们直接执行./level1的时候,buf的位置会固定在别的地址上。怎么解决这个问题呢?

最简单的方法就是开启core dump这个功能。当程序运行的过程中异常终止或崩溃,操作系统会将程序当时的内存状态记录下来,保存在一个文件中,这种行为就叫做Core Dump。可以认为是内存快照,除了内存信息外,还有一些寄存器信息,内存管理信息,其他处理器和操作系统状态和信息被保存下来。用下面这两条命令开启该功能:

1
2
ulimit -c unlimited
sudo sh -c 'echo "/tmp/core.%t" > /proc/sys/kernel/core_pattern'

开启之后,当出现内存错误的时候,系统会生成一个core dump文件在tmp目录下。然后我们再用gdb查看这个core文件就可以获取到buf真正的地址了。

image-20220411143033923

得到shellcode真正地址是0xffffd150

payload

image-20220411144041530

image-20220411144025934

果然出错了,还是之前查看程序的时候发现PIE和relro保护都有,把作者在github上的程序重新跑一下前面的步骤发现可行了

image-20220411145234664

远程部署

除了本地调试,还有远程部署的方式,如下,将题目绑定到指定端口上:

1
socat tcp-l:10001,fork exec:./level1

payload除了将p = process(“./level1”)改为p = remote(“127.0.0.1”, 10001)外,ret的地址还会发生改变。解决方法还是采用生成core dump的方案,然后用gdb调试core文件获取返回地址

image-20220411153012363

image-20220411153059184

image-20220411153110496

level2 - ret2libc绕过NX

还是首先看一下这个程序情况,发现只开了NX保护

image-20220411153712277

这样就不能和前面一样将shellcode写到栈上去,我们可以看一下,level1的stack是rwx的,但是level2的stack却是rw的

1
cat /proc/[pid]/maps

image-20220411154528160

既然开启了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

image-20220411155713622

payload

这里就不写了,很简单,和之前ret2libc差不多

level2 - ROP绕过NX和ASLR

就在上一题的基础上,不过需要打开ALSR,如果你通过sudo cat /proc/[pid]/maps或者ldd查看,你会发现level2的libc.so地址每次都是变化的:

image-20220411161110720

image-20220411161127819

所以这次如何利用呢?——思路是:先泄漏出libc.so某些函数在内存中的地址,再利用泄漏出的函数地址根据偏移量计算出system()函数和/bin/sh字符串在内存中的地址,最后执行我们的ret2libc的shellcode。既然栈、libc、堆的地址都是随机的,我们怎么才能泄露出libc.so的地址呢?方法还是有的,因为程序本身在内存中的地址并不是随机的,如图所示,Linux内存随机化分布图:

image-20220411161439026

所以我们只要把返回值设置到程序本身就可执行我们期望的指令了。

首先我们利用objdump来查看可以利用的plt函数和函数对应的got表:

image-20220411161818642

我们可以通过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文件来计算相对地址:

image-20220411162311187

除了用ldd命令查看libc.so库,还可以直接用pwntools库的elf.libc来获取libc.so库:

1
2
3
from pwn import *
elf = ELF("./level2")
libc = elf.libc

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位

image-20220411165400556

image-20220411165337387

level2 - 在不获取目标libc.so的情况下进行ROP攻击

如果我们在获取不到目标机器上的libc.so情况下,应该如何做呢?这时候就需要通过memory leak(内存泄露)来搜索内存找到system()的地址。

这里采用pwntools提供的DynELF模块来进行内存搜索。首先我们需要实现一个leak(address)函数,通过这个函数可以获取到某个地址上最少1 byte的数据。拿上一篇中的level2程序举例。leak函数应该是这样实现的:

1
2
3
4
5
6
def leak(address):
payload1 = 'a'*140 + p32(plt_write) + p32(vulfun_addr) + p32(1) +p32(address) + p32(4)
p.send(payload1)
data = p.recv(4)
print "%#x => %s" % (address, (data or '').encode('hex'))
return data

随后将这个函数作为参数再调用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就可以获取了。

image-20220411172320166

当然,可以在pwntools中直接调用elf.bss()获取.bss段地址:

1
2
elf = ELF("./level2")
bss_base = elf.bss()

因为我们在执行完read()之后要接着调用system(“/bin/sh”),并且read()这个函数的参数有三个,所以我们需要一个pop pop pop ret的gadget用来保证栈平衡。这里我们用ROPgadget来寻找:

image-20220411173204893

注意用python3会出现对DynELF模块不兼容的问题, python3和python2的最大区别(pwn中), 是bytes和str类型不共通了, 所以原本python2能跑的脚本, 在python3得考虑类型转换问题, DynELF中报错也是因为这个

1
2
3
4
5
sudo gedit /usr/local/lib/python3.8/dist-packages/pwnlib/dynelf.py
#把下面这一行改了
result = e.symbols[symb]
#改成这个
result = e.symbols[symb.decode()]

image-20220411194023196

image-20220411193850745

x64

level 3 - 通过 ROP 绕过 DEP 和 ASLR 防护

程序代码如下:

image-20220411195114336

image-20220411195303008

查看文件基本情况,发现是64位动态链接的文件,之开启了NX保护

我们用GDB调试一下,输入大量字符串,程序终止在vulnerable_function()函数处

image-20220411195704764

奇怪的事情发生了,PC指针并没有指向类似于0x41414141那样地址,而是停在了vulnerable_function()函数中。这是为什么呢?原因就是我们之前提到过的程序使用的内存地址不能大于0x00007fffffffffff,否则会抛出异常。但是,虽然PC不能跳转到那个地址,我们依然可以通过栈来计算出溢出点。因为ret相当于“pop rip”指令,所以我们只要看一下栈顶的数值就能知道PC跳转的地址了。

image-20220411195850173

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…

rp++: https://github.com/0vercl0k/rp

程序代码如下,看到程序在一开始运行时调用systemaddr()函数,该函数会从本程序用到的libc.so.6中获取其中的system()函数地址并打印出来:

image-20220411201005408

程序文件情况如下,只开了NX保护

image-20220411201337087

因为我们知道了溢出偏移量和system()函数的地址,剩下的就是通过寄存器给system()函数传参了,而在64位中传参的前六个参数是通过寄存器来实现的,而且system()只接受一个参数,因此我们需要找到一条pop rdi;ret的Gadget来帮助我们实现,这里我们用的是ROPgadget工具帮我们查找:

image-20220411202231371

image-20220411202305530

payload

先填充栈空间,到达 rip 上一个内存空间。覆写为gadget地址,再接着是/bin/sh内存地址,这样就可以将/bin/sh存入到 rdi 寄存器。

image-20220412103155409

level5 - 通用gadgets

因为程序在编译过程中会加入一些通用函数用来进行初始化操作(比如加载libc.so的初始化函数),所以虽然很多程序的源码不同,但是初始化的过程是相同的,因此针对这些初始化函数,我们可以提取一些通用的gadgets加以使用,从而达到我们想要达到的效果。

level5.c代码如下,相比于level3和level4,去掉了提供system()或其地址的辅助函数:

image-20220412105037820

这道题思路和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进行初始化操作。

image-20220412113304210

这个函数里先执行400606再执行4005f0有以下赋值过程

1
2
3
4
5
6
7
8
RDI,RSI,RDX
[RSP+0X8]->RBX
[RSP+0X10]->RBP
[RSP+0X18]->R12
[RSP+0X20]->R13;R13D->EDI
[RSP+0X28]->R14->RSI
[RSP+0X30]->R15->RDX
CALL [R12+RBX*8]

只要我们控制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字节上。

image-20220412153739968

image-20220412153831586

image-20220412153857551