总述
本篇文章将讲解basic-ROP,来让新手用结合题目exp和图示法栈帧结构的方式来进行pwn基础的学习。
主要讲解了ret2text,ret2system,ret2libc,ret2syscall,ret2csu这些题目的基本结构和利用。
讲解了ROPgadget和LibcSearch这两个工具的使用。
讲解了程序中栈的结构,延迟绑定机制,ASLR(地址空间布局随机化)保护
希望这篇文章可以帮到你。
栈的结构
栈用于存储函数调用过程中的局部变量、函数参数、返回地址等信息。 当程序启动时,操作系统会为程序分配一块连续的内存区域作为栈空间。在大多数系统中,栈的生长方向是从高地址向低地址。
栈指针寄存器(ESP/RSP)
- 情景:栈指针寄存器就像你在这摞书的最上面放了一个标记,它总是指向栈顶的位置。当你往栈里放一本书(压入数据)时,标记会向下移动;当你从栈里拿走一本书(弹出数据)时,标记会向上移动。
- 对应到计算机:栈指针寄存器(如 x86 架构中的
ESP
或 64 位系统的RSP
)始终指向栈顶。每进行一次压栈操作,它的值会减小(因为栈是从高地址向低地址生长);每进行一次出栈操作,它的值会增大。
基址指针寄存器(EBP/RBP)
- 情景:基址指针寄存器就像你在这摞书中间的某一层放了一个固定的书签,用来标记一个函数栈帧的起始位置。通过这个书签,你可以方便地找到这一层附近的书(局部变量和参数)。
- 对应到计算机:在函数调用时,会把当前的栈指针的值保存到基址指针寄存器(如
EBP
或RBP
)中,作为这个函数栈帧的基地址。函数可以通过基址指针和偏移量来访问局部变量和参数。比如,某个局部变量在基地址往下偏移 4 个字节的位置,就可以通过基址指针加上偏移量来找到这个变量。
栈帧在函数执行结束后不会被保留,其内存空间会被释放并可能被后续操作覆盖。以下是详细说明:
- 创建:当函数被调用时,系统会为其分配栈帧(通过调整栈指针
RSP
),用于存储参数、返回地址、保存的RBP
以及局部变量。 - 销毁:函数返回时,栈帧会被释放。具体操作包括:
- 通过
RET
指令恢复返回地址,跳回调用者。 - 恢复调用者的
RBP
值(从栈中弹出)。 - 调整
RSP
指针,将栈顶移回调用前的位置,释放当前函数的栈帧空间。
- 通过
栈帧是临时的,仅在函数执行期间存在。函数返回后,其栈帧会被释放,内存空间会被后续操作覆盖。
以下面函数为例:
1 |
|
1 | 高地址 |
如图所示:
调用了三次内部的函数,只有调用的函数才会被分配栈帧。在执行完成一个函数后会去调用函数内部引用的函数,当内部全部执行完成后再去下一个函数。所以是:
main-> ( vuln -> vuln2 ->(vuln) ) 由高到底去执行。
随着 NX (Non-eXecutable) 保护的开启,传统的直接向栈或者堆上直接注入代码的方式难以继续发挥效果,由此攻击者们也提出来相应的方法来绕过保护。
目前被广泛使用的攻击手法是 返回导向编程 (Return Oriented Programming),其主要思想是在 栈缓冲区溢出的基础上,利用程序中已有的小片段 (gadgets) 来改变某些寄存器或者变量的值,从而控制程序的执行流程。
接下来我们就学习一些关于ROP的技巧和方法。
ret2text
栈溢出:利用数据覆盖到ret的返回地址上面实现任意地址读,来实现控制程序执行流。
同样使用上面的程序作为例子:若vuln的返回地址被改写成了main函数的开始位置,那么在vuln函数执行完成之后就会去跳转回main函数,而不会执行下面的print语句。
对于该程序我们可以利用栈溢出把返回地址改写成backdoor的地址,然后去获得shell。
对应的exp为:
1 | from pwn import * |
ret
指令的核心功能
作用机制
(return)指令从当前函数返回调用者,具体操作:
- 弹出返回地址:从栈顶(ESP指向的位置)读取4字节(32位系统)或8字节(64位系统)作为返回地址。
- 跳转执行:将程序计数器(EIP/RIP)设置为弹出的地址,实现控制流转移。
与
call
的对应:call
指令调用函数时会压入返回地址,ret
则逆向完成这一过程。
为何需要ret
后才能跳转?
- 栈溢出攻击通过覆盖返回地址(位于栈帧中保存的
EIP
位置)来劫持程序流。 - 攻击者需等待函数执行到
ret
指令时,系统主动从栈中加载返回地址到EIP,此时被覆盖的恶意地址才会生效。
ret2system
先介绍两个函数原型等会利用:
1 |
|
现在可以利用栈溢出来控制程序执行流了,我们就可以跳转去执行我们的指令。
没有现成的backdoor但是有system函数和bin/sh字段可以调用,我们可以手动构建一个backdoor。
构建的backdoor在栈帧里面大概如下图:
1 | +---------------------+ |
1 | from pwn import * |
再进一步如果连”bin/sh”这个字符串都没有的话,我们也可以去构建一个bin/sh,然后再去构建我们自己想要的后门。还是利用栈帧图我们更好理解:
1 | +---------------------+ <--- 低地址(栈顶) |
下面是具体的exp脚本:可以一一对应。
1 | from pwn import * |
ret2libc
在很多时候,我们程序中肯定不会留出后门函数system的,这时候,我们即没有system函数,也没有”\bin\sh”的字符串,这时候我们该如何利用漏洞呢?
比如说,我们在一个C语言程序中调用了printf函数,这个函数不是我们自己实现的,而是使用了链接库,但是这里有一个问题,使用链接库的时候,链接库所有的函数都被加载进来了,也就是说,system函数也被加载进来了,这时候我们就就可以使用system函数了。
在使用连接库内部的函数是还有一个问题,就是我们怎么去寻找这个函数在链接库内的位置。
接下来要介绍两个概念一个是延迟绑定机制,一个是ASLR(地址空间布局随机化)保护。
延迟绑定:
在链接库内部有很多函数,一个程序不是所有函数都要使用,如果我们都加载出来会非常影响我们程序的效率。延迟绑定就是为了解决这个问题。
延迟绑定(Lazy Binding)是一种动态链接技术。在动态链接中,程序所依赖的共享库函数在运行时才会被链接到程序中。如果在程序启动时就将所有依赖的共享库函数都进行绑定(即确定函数的实际地址),会增加程序的启动时间。延迟绑定机制则是将函数地址的绑定推迟到该函数第一次被调用时进行。
比如print()函数就是在glibc库里面的 ,只有当程序运行起来的时候才知道地址。我们只有获得print的重定位地址才可以去执行他。在这个时候链接器会生成一小段额外代码去执行跳转到print函数的地方去。所以我们需要两个数据来去获得print函数的执行。
一个是got 表全局偏移表,里面存放了print(外部函数的数据的表)也就是真实的地址。
一个是plt表程序链接表,里面存放了去找print(定位外部函数的额外程序的表)是定位的地址
简单来说就是链接库内的函数一开始时地址时未知的,可以认为就是随机的。那么我们的目标函数system也就找不到了,但是好在每一次随机的地址都是libc的基地址,链接库内的函数地址都是根据一个基地址的相对偏移来定位。
假设print真实地址==基地址+1,
system真实地址==基地址+12,
那么system==print真实地址+11.
我们如果获得了print的真实地址。利用真实地址减去偏移量就可以获得基地址,再用基地址去加上我们目标函数的偏移量,就可以获得目标函数的真实地址。
ASLR
地址空间布局随机化,在我们知道如何利用libc动态链接库去获得想要的system函数时,开发人员为了保护程序也有对应的保护措施,就是这个。
ASLR(Address Space Layout Randomization,地址空间布局随机化)是一种 内存安全防护技术,通过 随机化程序关键数据区域(如栈、堆、libc)在内存中的加载地址,防止攻击者可靠地定位并利用内存漏洞(如缓冲区溢出、ROP攻击)。
核心目标:增加漏洞利用的难度,使攻击者无法预测目标函数或数据的准确地址。
随机化范围
- 栈(Stack):函数返回地址、局部变量的位置随机化。
- 堆(Heap):动态分配的内存块基地址随机化。
- 共享库(如libc):库函数的加载地址随机化。
例题解析
静态寻找(无 ASLR 时)
- 方法
- 使用
gdb
调试目标程序,找到某个 libc 函数(如puts
、printf
)的运行时地址a
(通常高位为0x7f
)。 - 用
IDA
打开libc.so
文件,查找该函数的 偏移地址b
。 - libc 基地址 =
a - b
。
- 使用
- 适用场景
- 适用于 ASLR 关闭 的情况(如某些 CTF 题目)。
动态寻找(ASLR 开启时)
方法
通过 泄露 libc 函数地址(如利用
puts(puts@got)
泄露puts
的实际地址)。使用 LibcSearcher 或 在线查询工具 匹配泄露的地址,确定 libc 版本。
1
2
3
4
5
6from LibcSearcher import LibcSearcher
# 输入泄露的函数名和地址
leak_func = "puts"
leak_addr = 0x7f8a3b02a420
# 初始化查询器
libc = LibcSearcher(leak_func, leak_addr)根据 libc 版本,计算目标函数的偏移量,最终得到基地址:
1
2libc_base = leaked_puts_addr - libc.symbols['puts']
system_addr = libc_base + libc.symbols['system']
适用场景
- 现实世界或 ASLR 开启 的环境 。
我们可以再次写一个类似的栈帧空间:
1 | +---------------------+ <--- 低地址(栈顶) |
对应的exp如下:
1 | from pwn import * |
提示,libc的基地址的末尾一般是000结尾的,我们可以打印出它的16进制表示,来验证一下是不是真的算对了。
ret2syscall
有些题目连动态链接库都不给我们呢?在接下来的学习中我们不能简单利用栈的执行去getshell了。我们要利用很多关于程序本身的机制很功能来实现go2backdoor。
介绍一个工具:
ROPgadget
1 | # 通过pip安装 |
基本使用:
1 | # 搜索二进制文件中的所有gadgets |
ROPgadget 是 ROP攻击开发的核心工具,其优势在于:
例题解析
接下来就可以学习ret2syscall-系统调用。 ret2syscall(Return-to-System-Call)是一种ROP攻击技术,通过控制程序执行系统调用来获取系统权限(如执行/bin/sh
)。其核心思想是利用程序中的代码片段(gadgets)构造系统调用所需的寄存器状态,最终触发系统调用指令。
系统调用基础
- 系统调用机制:用户程序通过特定指令(32位:
int 0x80
;64位:syscall
)请求内核服务。 - 关键寄存器
- 32位:
eax
(系统调用号),ebx
、ecx
、edx
(参数)。 - 64位:
rax
(调用号),rdi
、rsi
、rdx
(参数)。
- 32位:
- 常用调用:
execve("/bin/sh",0,0)
(32位调用号0x0b,64位0x3b)。
这个系统调用相当于 用户态请求内核态服务的“桥梁”。它允许应用程序执行需要更高权限的操作(如文件读写、进程管理、时间获取等)
1 | from pwn import * |
提供一个系统调用号的表格帮助实现更多操作。
系统调用功能 | 32位调用号 | 64位调用号 | 说明 |
---|---|---|---|
execve |
11 | 59 | 执行程序(Pwn中常用于触发shell) |
exit |
1 | 60 | 终止进程 |
read |
3 | 0 | 从文件描述符读取数据(Pwn中用于输入) |
write |
4 | 1 | 向文件描述符写入数据(Pwn中用于输出) |
open |
5 | 2 | 打开文件 |
close |
6 | 3 | 关闭文件描述符 |
fork |
2 | 57 | 创建子进程 |
mmap |
90 | 9 | 内存映射(Pwn中用于构造可执行内存区域) |
mprotect |
125 | 10 | 修改内存保护属性(Pwn中用于绕过NX) |
socket |
359 | 41 | 创建套接字(网络Pwn题常用) |
sendfile |
187 | 40 | 文件传输(Pwn中用于数据泄露) |
ret2csu
在上面几个栈溢出的使用中我们可以发现我们常对各个函数出手,去使用他们,函数中有个很关键的点就是参数,设置好对应的参数才可以让函数为我们所用。
其实对于不同的架构下的程序,参数的存放也是有讲究的。如x86 与 x64 的区别:
x86 都是保存在栈上面的, 而 x64 中的前六个参数依次保存在 RDI, RSI, RDX, RCX, R8 和 R9 中,如果还有更多的参数的话才会保存在栈上
x64程序里,函数的前 6 个参数是通过寄存器传递的,但是大多数时候,我们很难找到每一个寄存器对应的 gadgets。 这时候,我们可以利用 x64 下的 __libc_csu_init 中的 gadgets。 __libc_csu_init 主要用于 初始化 libc 和全局构造器 其核心功能是在 main
函数执行前完成必要的库初始化工作 。一句话,比较常见。
libc_csu_init内有两段gadget。
1 | # Gadget 1: 控制寄存器的 pop 指令序列 |
案例关于”利用csu去执行syscall来getshell“如下:
1 | #案例 |