【学习记录】Week4(四):进阶栈溢出——ret2syscall、栈劫持与 ret2mprotect 实战

写在前面:在前三篇文章中,我们掌握了 ret2libc 泄露、基础 ROP 拼接以及大杀器 ret2csu。但 CTF 的世界总是千奇百怪:如果题目没有给你 libc,或者程序里根本找不到syscall指令怎么办?如果溢出的空间极其狭小,连 ROP 链都放不下怎么办?本文作为 Week4 的收官,将带你学习三种高阶栈溢出技巧,彻底补全你的基础攻击面。

📑 目录

  1. 越过 libc:ret2syscall 直接调 execve
  2. 空间魔术:栈劫持与 fake stack frame 构造
  3. 破除禁锢:ret2mprotect 修改内存权限
  4. Week4 总结与进阶展望

1. 越过 libc:ret2syscall 直接调 execve

场景痛点:有时候题目是静态编译的,或者没有输出函数让我们泄露 libc 基址。此时 ret2libc 走不通。
破局思路:既然程序静态编译了,那它内部大概率包含了syscall指令。我们不需要调用system函数,而是直接通过 ROP 链布置寄存器,最后执行syscall指令,直接触发操作系统的execve("/bin/sh", NULL, NULL)系统调用。

64 位 execve 系统调用约定:

  • rax= 0x3b (59,execve 的系统调用号)
  • rdi= “/bin/sh” 字符串地址
  • rsi= 0 (argv)
  • rdx= 0 (envp)
  • 最后执行syscall指令

假设性说明(模拟 Gadget 查找):
使用 ROPgadget 查找静态编译的程序:

ROPgadget --binary vuln --only "pop|ret" | grep rax # 模拟输出: 0x0000000000401b8f : pop rax ; ret ROPgadget --binary vuln --only "pop|ret" | grep rdi # 模拟输出: 0x0000000000401b93 : pop rdi ; ret ROPgadget --binary vuln --string "/bin/sh" # 模拟输出: 0x00000000006c1000 : /bin/sh ROPgadget --binary vuln --only "syscall|ret" # 模拟输出: 0x0000000000401b95 : syscall ; ret

(假设我们通过 ret2csu 或其他方式已经将rdxrsi置零了)

ROP 链拼接推演:

payload = b'A' * offset # 假设已经处理了 rsi 和 rdx 为 0 payload += p64(pop_rdi) + p64(bin_sh_addr) payload += p64(pop_rax) + p64(0x3b) payload += p64(syscall_addr)

通过这种方式,我们完全绕过了 libc,直接让 CPU 替我们执行系统调用。

2. 空间魔术:栈劫持与 fake stack frame 构造

场景痛点:有时候程序的溢出点非常小,比如read(0, buf, 0x20),除去 16 字节的填充和 8 字节的返回地址,我们只剩下 8 字节的空间,根本塞不下完整的 ROP 链。
破局思路:既然当前的栈空间不够,那我们就把 ROP 链写到其他地方(如 BSS 段),然后把栈指针(RSP)劫持过去

核心指令:leave; ret
回顾汇编知识,leave指令等价于:

mov rsp, rbp ; 把 rbp 的值赋给 rsp pop rbp ; 把此时栈顶的值弹入 rbp

如果我们能控制rbp的值,然后执行leave; ret,就能瞬间改变rsp的指向!这就是“栈迁移”或“伪造栈帧”的核心。

假设性实战推演:
假设溢出偏移为 16(即 16 字节后覆盖到 rbp),我们只能控制rbp和返回地址。我们的目标是把栈迁移到bss_addr

  1. 第一次溢出时,我们在bss_addr提前写好完整的 ROP 链。
  2. 构造第一次的 Payload:
    • rbp被覆盖为bss_addr - 8(因为leave中的pop rbp会弹出一个 8 字节,我们需要让执行完pop rbp后,rsp正好指向bss_addr)。
    • 返回地址覆盖为程序中某个leave; ret指令的地址。

栈结构设计:

低地址 | 16字节填充 | | bss_addr - 8 (覆盖 rbp) | <- 此时原 rbp 被篡改 | leave_ret_addr (返回地址)| <- ret 跳到 leave; ret 执行 高地址

当程序执行原本的leave; ret(或者跳到我们自己构造的leave; ret)时:

  1. mov rsp, rbp->rsp变成了bss_addr - 8
  2. pop rbp->rsp变成了bss_addr,此时栈顶就是我们提前写好的完整 ROP 链!
  3. ret-> 开始执行我们在 BSS 段布置的 ROP 链。

这种技术在空间极其狭小的栈溢出中是起死回生的神技。

3. 破除禁锢:ret2mprotect 修改内存权限

场景痛点:程序开启了 NX 保护,栈和 BSS 段都不可执行。我们想用 Shellcode,但系统不让执行。
破局思路:Linux 提供了mprotect函数,可以修改内存页的权限。如果我们通过 ROP 调用mprotect(bss_addr, 0x1000, 7),就能把 BSS 段改成可读可写可执行(rwx,7 = 4+2+1),然后跳过去执行 Shellcode。

函数原型:int mprotect(void *addr, size_t len, int prot);

  • addr必须是内存页的整数倍(通常是 0x1000 对齐,如0x404000
  • len是长度
  • prot是权限,7 代表PROT_READ | PROT_WRITE | PROT_EXEC

攻击步骤规划:

  1. 使用 ret2csu 或基础 ROP,控制rdi = 0x404000rsi = 0x1000rdx = 7
  2. 调用mprotect
  3. 接着调用read(0, 0x404000, 0x100),将我们的 Shellcode 读入 BSS 段。
  4. 最后ret跳转到0x404000执行 Shellcode。

假设性说明(模拟 ROP 链结构):

payload = b'A' * offset # 1. 调用 mprotect(0x404000, 0x1000, 7) payload += p64(pop_rdi) + p64(0x404000) payload += p64(pop_rsi) + p64(0x1000) # 假设 rdx 已经是 7,或者用 csu 控制 payload += p64(mprotect_addr) # 2. 调用 read(0, 0x404000, 0x100) 把 shellcode 读进去 payload += p64(pop_rdi) + p64(0) payload += p64(pop_rsi) + p64(0x404000) payload += p64(pop_rdx) + p64(0x100) payload += p64(read_addr) # 3. 跳转到 0x404000 执行 payload += p64(0x404000)

read读取完我们发送的 Shellcode 后,ret指令会精准跳到 BSS 段,此时该区域已是rwx权限,Shellcode 顺利执行。

4. Week4 总结与进阶展望

至此,Week4 的四大模块全部完成!
从最基础的ret2libc 泄露与劫持,到拼接任意函数调用的基础 ROP;从解决 64 位传参痛点的ret2csu,到直接越权系统调用的ret2syscall;再到空间魔术般的栈劫持和改变内存属性的ret2mprotect

如果说 Week1-Week3 是让你认识了 PWN 的武器库,那么 Week4 就是教你如何组合这些武器,形成一套完整的攻击体系。掌握了这些,常规的栈溢出题已经很难挡住你的脚步。

下周预告 (Week5)
栈上的基础利用我们已基本讲完。但在实际环境中,栈上的保护(Canary)越来越严密。下周我们将正式踏入的世界,从mallocfree的底层实现讲起,揭开Use-After-Free (UAF)Double FreeTcache机制的神秘面纱。堆利用才是现代 PWN 的主战场!

如果 Week4 的系列文章对你的学习有帮助,请点赞收藏支持!你的鼓励是我持续更新的最大动力。我们 Week5 见!🙏