基本ROP
基本ROP
参考资料
CTF-wiki:基本ROP
语雀:PWN入门(1-3-1)-基本ROP-介绍
《深入理解计算机系统》
0x01 什么是ROP
ROP,全称Return Oriented Programming,中文翻译为返回导向编程,其基本思想为在缓冲区溢出的基础上,利用程序中已经有的小片段(gadgets)来改变某些寄存器的值,从而控制程序的执行流。
gadgets通常是以 ret 结尾的指令序列, 通过这样的指令序列,我们可以多次劫持程序的控制流,从而运行特定的指令序列,以完成攻击目的。
使用ROP攻击一般满足如下条件:
- 程序存在缓冲区溢出漏洞(不局限于栈溢出),且允许我们更改返回地址
- 程序中可以找到满足条件的 gadgets 片段以及相应的 gadgets 地址
0x02 ret2text
ret2text可以算是ret to text的简写,其中的text代表的是.text段,其中.text段是存放程序中所有可执行代码的地方。简单来说,ret2text就是让程序中某个有漏洞的函数,在返回时,eip寄存器不会返回其调用者函数的代码上,而是跳转到 .text 段上的 gadgets 上。并且,利用 gadgets 中的ret指令,我们甚至可以多跳转几次,让程序执行好几段不相邻的代码。
下面是一个简单的 ret2text的实例
1 | |
使用如下命令编译
1 | |
下面我们按照一般打题的流程来走一遍
先来查一下程序的保护
1 | |
保护全关,并且是32位程序,虽然有源码,但是我们依旧要使用IDA,锻炼自己的能力。
我们直接来到vuln函数
1 | |
可以看到,这里直接是用了 gets 函数,没有检查输入大小,所以有栈溢出漏洞,在IDA的反编译界面,我们双击buf变量,可以看到 vuln 函数的栈帧分布

可以看到,buf 在栈中的相对地址为 ebp-0x18,我们知道,ret2text需要控制某个函数返回地址,让其变成.text段上的某个 gadgets 的地址。在这个程序中,gets 函数会一直向 vuln 的栈中填充我们输入的数据,直到收到我们输入的 \n,那我们是不是可以构造一个特殊的输入,先让它填充 vuln 的栈帧空间 + 返回地址,然后在最后加上 gadgets 的地址,这样我们就可以覆盖掉返回地址,变成 gadgets 的地址
思路有了,我们开始找 gadgets ,一般来说,我们会从两个地方找 gadgets,一个是IDA的 Functions 窗口

你可以找到一些有着特殊命名的函数,比如这里的 backdoor 函数。
另一个就是使用 Shitf + f12 打开 IDA 的 String 窗口,按 Ctrl + F 搜索 “/bin/sh” 这种方法更加泛用,毕竟不是每个这种题目都会有一个叫 backdoor 的函数。

双击 /bin/sh 字符串,然后 IDA 会跳转到 IDA view-A 窗口,同时高亮在 .rodata段中的 /bin/sh 字符串,然后按 Ctrl + X 显示交叉引用,它就会跳出来另一个窗口,里面会显示使用这个字符串的,双击该函数,你就会跳转到该函数的汇编代码上。
两种方式,都可以找到 backdoor 函数。
现在,该有的信息都有了,我们就可以来编写漏洞利用脚本了
1 | |
首先,是每个脚本都会有的“八股文”,目的是确定上下文,这里就不展开讲了,这不是重点,唯一需要注意的是,如果是在本地 pwn,需要写io = process("程序路径"), 脚本会自动打开程序并按照编写的逻辑去与程序交互,如果是pwn在远程服务器上程序,需要换成io = remote("ip或者域名", 端口);
下面,写一个变量,就叫backdoor,用来存放 gadgets 也就是 backdoor 函数的地址,但是,我们需要注意的是,我们不能直接使用 backdoor 的地址,即0x080491F0
1 | |
具体原因我目前也没有太搞明白,但是就做题经验而言,使用0x080491F1作为返回地址,即后门函数的首地址 +1,成功率远远大于直接使用后门首地址。
接下我们就来构造payload
1 | |
其中 b'a' * (0x18 + 0x4),的作用是向栈中填充垃圾数据,因为 gets 先栈中写入数据是从低地址写到高地址的,而返回地址位于高地址,比栈底还要高一个机器字长,要想覆盖掉返回地址,我们需要将他前面的空间全部填满才行。0x18是目标缓冲区,这里是 buf 的首地址与栈底之间的距离,而 0x4 是是栈底的大小,所以我们需要覆盖(0x18 + 0x4)的大小。
而p32的作用时,将十六进制整数,按照小端序的打包成四字节。
小端序:一个多字节数据在内存中存放时,低位字节放在低位
比如说我这里有一个十六整数:0x12345678,其中,12为高位,78为低位,那么按照小端序的存放方式,0x12345678在内存中的实际储存方式为
1
2
3
4
5| addr | value |
| 0x1001 | 0x78 |
| 0x1002 | 0x56 |
| 0x1003 | 0x34 |
| 0x1004 | 0x12 |一般来说,向内存中写入数据都是从低地址写向高地址,以写入的视角看,这个十六进制数就是倒着的
最后,我们就可以写出完成的exp:
1 | |
0x03 ret2libc
关于ret2libc
ret2libc 这种攻击方式主要是针对是那些使用动态编译的程序,并且在正常情况下,无法找到向 system() 和 execv() 这样的系统函数去直接或间接的拉起 /bin/sh 程序。这种时候,我们就可以考虑使用ret2libc,因为动态编译的程序会在运行的时候调用 libc.so 动态链接库中的代码,而这个动态链接库中几乎包含了所有我们能用到的库函数,包括system() 和 execv()。如果我们有方法调用 libc.so 中的 system()函数,构造出 system(“/bin/sh”), 我们就可以 getshell。
关于动态编译
编译过程[1]
编译器将源程序文件,也就是你的源代码文件(hello.c),编译成一个可执行程序,需要经历以下四个步骤:
预处理
展开#include, 宏替换,条件编译,输出仍是文本格式的,但是经过修改的源程序:hello.i编译器
将预处理后的源码翻译成汇编语言,此时文件变成hello.s汇编器
把汇编翻译成机器码目标文件(hello.o),里面有代码/数据,但很多符号地址还没定。链接
将多个.o和库组成最终的可执行文件,然后做符号解析,重定位,生成可执行文件
而动态链接和静态连接的主要区别就在于“链接”这一步
静态链接
链接器在链接阶段就把你用到的库函数实现(来自 .a 静态库)拷贝进最终可执行文件里,并尽可能把符号地址都在这时确定好。
结果:
可执行文件更大
运行时不依赖系统的 .so
没有“运行时再去找 libc.so”这一步(或非常少)
动态链接
链接器在链接阶段不会把动态共享库的代码打包进ELF文件,而是在 ELF 里塞进动态链接所需的元数据:
PT_INTERP:告诉内核“解释器/动态加载器是谁”(如 ld-linux-x86-64.so.2)
.dynamic:一堆 DT_* tag(依赖库、重定位信息、符号表位置、hash 表、RPATH/RUNPATH…)
.dynsym/.dynstr:动态符号表/字符串表(运行时要用)
.rela.dyn / .rel.dyn:需要在装载时修补的重定位(data/指针类)
.rela.plt / .rel.plt:PLT 相关重定位(外部函数调用相关)
GOT/PLT:把“外部函数调用”变成可延迟解析的跳板机制
延迟绑定
PLT表 & GOT表
PLT(Procedure Linkage Table)
是一段段小函数(stub / trampoline)
每个外部函数通常对应一个 PLT 条目:puts@plt、printf@plt…
功能:通过 GOT 里存的地址跳转到真实函数,并支持“第一次调用时去解析”。
GOT(Global Offset Table)
是一张表(内存里的数组)
表项存放“某个符号最终解析到的真实地址”
对外部函数来说,真正被 PLT 用的那一块 GOT,通常在节里叫 .got.plt
延迟绑定
这部分如果要详细介绍的话会非常多,但是对打pwn题来说,详细介绍就有点多余了,如果未来有精力,我会完善这部分内容。
当程序第一次调用某个函数时,比如 puts 函数。由于其为动态链接程序,程序中并没有 puts 函数的相关代码,并不会执行 call puts 指令,而是call puts@plt, 程序会跳转到 plt 表中。上文说到,plt 表中实际上一段段小函数,实际上只会执行一个指令:jmp [puts@got.plt],意思是从got.plt表中取 动态库中 puts 函数的地址并跳转过去。但由于是第一次调用, got.plt表中并没有动态库中 puts 函数的实际地址。所以程序之后会跳转到一个公共入口,通常叫PLT0。
之后PLT0会把“我要解析哪个符号”这个信息(常见是“重定位表索引”)交给 ld.so 的 resolver
动态链接器做:
在已加载对象里查找符号 puts(考虑符号可见性、版本等)
找到 libc 里的真实 puts 地址
把这个真实地址写回 puts@got.plt 对应的 GOT 表项(这一步叫 fixup)
解析完成之后,程序会跳转到 libc 中的 puts 执行
当程序第二次调用 puts 时,仍然会先执行 call puts@plt 然后 jmp [puts@got.plt] 跳转到 got.plt 表上,此时 got.plt 表上已经有 puts 的真实地址了,程序会直接跳转到该地址上执行。
简而言之就是,第一次调用某个函数,查表,表上没有该函数的地址,然后去解析该函数的真实地址,并将真实地址写回表内。当第二次调用时,程序继续查表,发现有这个函数的地址,程序就会直接跳转到该地址上执行。
如何利用
现在的大多数操作系统都会开启 ASLR (地址空间布局随机化),这会导致,每次程序在运行的时候,共享库会被加载到一个随机的基地址上。不过好消息是,虽然加载时基地址会变,虽然加载时基地址会变类比成的数组的首地址,但是动态库是已经写死的东西,库内的函数偏移不会变的。举个例子就是:
假如某个动态库,我们把它想象成数组,就叫libc。其内有两个函数,一个是 puts 它的索引是libc[3], 一个是write,它的索引是 libc[4], 如果我此时加载的基地是 0x10000, 那么 puts函数的真实地址就是 0x10003,write的真实地址就是 0x1000write的真实地址就是 x10004
所以
- 该部分参考了部分《深入理解计算机系统》中的内容 ↩