第2章:ROP基础与ret2text——从概念到实战

好,咱们正式开始聊ROP。说实话,我第一次接触ROP的时候,觉得这东西特别玄乎——一堆看似不相关的代码片段拼在一起,居然能完成任意操作。后来做多了才发现,ROP其实就是一门「借力打力」的艺术。

2.1 ROP链到底是什么?

ROP的全称是Return-Oriented Programming,中文叫「面向返回的编程」。名字听着高大上,但核心思想很简单:程序里原本就有一堆可执行的代码,我们只是通过控制栈上的返回地址,让程序按我们的意愿去执行这些代码片段。

这些代码片段有个专门的名字——gadget。每个gadget通常以ret指令结尾,长度从几条指令到十几条不等。我们把多个gadget串起来,就形成了一条ROP链。

核心理解:ROP链的本质就是「用ret指令把gadget串成一条执行路径」。你不需要写新代码,只需要找到合适的gadget,然后安排好它们的执行顺序。

我个人习惯把ROP链比作「搭积木」:每个gadget是一块积木,栈上的返回地址就是积木的接口。你只要知道每块积木的形状(功能),就能搭出你想要的结构。

2.2 ret2text——最简单的ROP攻击

ret2text是ROP家族里最入门、最友好的攻击方式。它的前提条件很明确:目标程序里已经存在一个「后门函数」或「危险函数」,我们只需要把执行流跳转过去就行。

说白了,ret2text就是「程序里有人帮你写好了shellcode,你只需要想办法调用它」。我在CTF比赛中遇到过好几次这种情况——出题人故意留了个win函数或者backdoor函数,就看你能不能找到并跳转过去。

2.2.1 攻击原理

ret2text的攻击流程其实就三步:

  1. 溢出覆盖:通过栈溢出,覆盖返回地址
  2. 地址替换:把返回地址改成目标函数的入口地址
  3. 函数执行:函数返回时,CPU跳转到目标函数执行

你想想看,正常情况下函数返回会回到调用者那里继续执行。但如果我们把返回地址改了,CPU就会跳到我们指定的地方。这就是ret2text最朴素的思路。

小提示:ret2text不需要构造复杂的ROP链,只需要一个gadget——就是目标函数本身的地址。所以它也是学习ROP最好的起点。

2.2.2 实战案例

来看一个典型的例子。假设程序里有这样一个后门函数:

void backdoor() {
    system("/bin/sh");
}

而main函数存在栈溢出:

void vulnerable() {
    char buf[64];
    read(0, buf, 0x100);  // 明显溢出
}

我们的目标就是让vulnerable函数返回时,跳转到backdoor函数。攻击代码大概长这样:

from pwn import *

# 连接目标
p = process('./vuln')

# 获取backdoor地址
backdoor_addr = 0x08048456

# 构造payload:填充64字节 + 覆盖ebp(4字节) + 返回地址
payload = b'A' * 64  # 填充buf
payload += b'B' * 4   # 覆盖ebp
payload += p32(backdoor_addr)  # 返回地址改成backdoor

p.send(payload)
p.interactive()

这里有个细节要注意:栈布局是 buf(64字节) + ebp(4字节) + 返回地址(4字节)。所以我们需要先填满64字节的buf,再覆盖4字节的ebp,最后才是返回地址。

我曾经踩过的坑:32位程序和64位程序的地址长度不一样。32位用p32()打包地址,64位用p64()。我第一次做64位ret2text时用了p32,结果地址被截断,程序直接崩了。嗯,从那以后我每次都会先确认目标架构。

2.3 寻找gadget

ret2text虽然简单,但大多数情况下程序里并没有现成的后门函数。这时候就需要我们自己找gadget来拼凑功能了。

寻找gadget的工具主要有两个:

工具 特点 适用场景
ROPgadget 功能全面,支持多种架构 通用场景,适合初学者
ropper 速度快,支持自定义搜索 大型二进制文件,效率优先

我个人更习惯用ROPgadget,因为它输出格式清晰,而且支持Python集成。基本用法是这样的:

# 命令行搜索所有gadget
ROPgadget --binary ./vuln

# 搜索特定指令
ROPgadget --binary ./vuln --only "pop|ret"

# 搜索带偏移的gadget
ROPgadget --binary ./vuln --only "pop|ret" --depth 10

2.3.1 常用gadget类型

在构造ROP链时,有几类gadget是高频出现的:

  • pop-ret:从栈上弹出数据到寄存器,然后返回。用于设置函数参数
  • pop-pop-ret:连续弹出两个值,适合设置两个参数
  • ret:单纯的返回指令,用于对齐栈或跳过不需要的指令
  • syscall/int 0x80:系统调用指令,用于执行系统调用

关键点:寻找gadget时,重点关注那些以ret结尾的指令序列。因为ROP链的每个环节都必须以ret结束,这样才能继续执行下一个gadget。

2.4 构造简单的ROP链

好了,现在我们来动手构造一条真正的ROP链。假设我们要调用system("/bin/sh"),但程序里没有现成的后门函数。我们需要:

  1. 找到system函数的地址
  2. 找到"/bin/sh"字符串的地址
  3. 找到pop rdi; ret这个gadget(用于设置第一个参数)

构造出来的ROP链长这样:

# 栈布局(从低地址到高地址)
payload = b'A' * 64          # 填充buf
payload += b'B' * 4          # 覆盖ebp
payload += p64(pop_rdi_ret)  # 返回地址 -> pop rdi; ret
payload += p64(bin_sh_addr)  # rdi = "/bin/sh"地址
payload += p64(system_addr)  # 调用system("/bin/sh")

执行流程是这样的:

  1. vulnerable函数执行ret,跳转到pop rdi; ret
  2. pop rdi从栈上弹出"/bin/sh"地址到rdi寄存器
  3. ret执行,跳转到system函数
  4. system函数以rdi为参数执行,弹出shell

经验之谈:构造ROP链时,我建议先在脑子里模拟一遍执行流程。从第一个gadget开始,一步步想清楚每个指令执行后栈指针和寄存器的变化。这样能避免很多低级错误。

2.5 本章知识体系

下面这张图展示了本章的核心知识结构:

ROP基础与ret2text知识体系 ROP链 ret2text攻击 寻找gadget ROP链构造 溢出覆盖返回地址 跳转到后门函数 pop-ret系列 syscall/int 0x80 设置参数寄存器 串联多个gadget 核心要点 • ret2text是最简单的ROP攻击,适合入门学习 • gadget是ROP链的基本单元,每个gadget以ret结尾 • 构造ROP链时,要确保栈布局与gadget的执行顺序匹配

2.6 避坑指南

做ret2text和ROP链时,有几个坑我反复踩过,分享给大家:

  • 地址对齐问题:64位系统要求栈16字节对齐,否则调用system等函数时会崩溃。我一般会在ROP链开头加一个ret gadget来对齐
  • ASLR的影响:现代系统默认开启地址随机化。ret2text需要知道确切地址,所以要么关闭ASLR,要么配合信息泄露
  • 栈上的坏字符:有些函数(如gets)遇到换行符会停止读取。如果gadget地址包含坏字符,需要换一个
  • gadget的可用性:不是所有找到的gadget都能用。要确认gadget所在的段是可执行的,而且没有被重定位

特别提醒:ret2text虽然简单,但它是理解所有高级ROP攻击的基础。我见过很多新手一上来就学ret2dlresolve或者SROP,结果基础没打牢,后面越学越乱。建议先把ret2text吃透,再往深了走。