Ret2Text入门实战:栈溢出控制流劫持与后门函数利用详解
Ret2Text这名字听起来像什么高深魔法说穿了其实就一句话程序自己代码里藏了一个能拿flag的后门函数我们只需要把程序的执行流强行掰到那个函数门口就行了。在CTF pwn的入门序列里它几乎总是和栈溢出绑定出现属于绕不开的固定节目。这次拆解的是一道编号为pwn 036的典型题目核心就两个点——栈溢出怎么发生、怎么精准利用以及后门函数该怎么找、怎么跳。这篇文章我会把从拿到题目到拿到flag的完整流程走一遍原理、命令、脚本、坑点全放出来适合刚接触pwn、会一点Linux基础但还没系统做过栈溢出题的朋友。1. 开局三板斧环境准备与情报收集1.1 file 和 checksec先搞清二进制的底细拿到题目给的附件别急着丢进反汇编工具里先做最基础的两个命令。第一个是 file看这个文件到底是什么架构、什么格式、有没有符号信息。$ file pwn036 pwn036: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]..., not stripped读结果只需要抓几个关键点64位、x86-64、动态链接、not stripped。not stripped 意味着符号表还在函数名能直接看到这对我们找后门函数非常友好。如果是 stripped 就得靠字符串搜索和特征判断了。第二个命令是 checksec这个必须养成习惯。它检查二进制开启了哪些安全防护机制直接决定我们用哪种利用手法。$ checksec --file./pwn036 Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)重点看四个字段Stack 显示 No canary found说明栈上没放金丝雀溢出不会触发检测PIE 显示 No PIE说明代码段地址固定后门函数的地址可以直接写死在 payload 里NX enabled 说明栈不可执行但这道题无所谓因为我们用的是 ret2text根本不需要在栈上执行代码RELRO 是 Partial暂时用不上知道有这个东西就行。这两条命令跑完这道题的利用策略基本就定下来了栈溢出覆盖返回地址跳到代码段里已有的后门函数完成利用。1.2 黑盒跑一遍观察程序行为和脆弱点拿到程序先别急着静态分析直接跑起来看它想干什么。这一步能快速确认漏洞点的触发方式。$ ./pwn036 Welcome to PWN036, please input your message:程序打印一行提示然后等待输入。试着输入一串普通的字符串程序会原样回显一下就结束。这时候换成超长输入试试$ python3 -c print(A*200) | ./pwn036 Welcome to PWN036, please input your message: Segmentation fault直接段错误。这个现象说明程序使用了类似 gets、read 这类不检查长度的输入函数我们的超长输入把栈上的返回地址给冲掉了程序往回跳的时候找不到合法地址就崩溃了。段错误本身不重要但它验证了一个关键事实漏洞点就在这个输入函数上而且返回地址确实可控。这就是栈溢出的典型特征。注意有些程序崩溃前会打印额外信息某些信息泄露类题目就是靠崩溃方式的差异来推断地址不过这道题不需要搞那么复杂。1.3 情报汇总这些信息决定了打法到这里可以做个阶段总结也顺便解释为什么 checksec 的每个字段都和后面的选择挂钩。防护机制题目状态这是否影响 ret2text 打法Canary关闭可以直接覆盖返回地址不需要先泄露金丝雀PIE关闭代码段地址固定后门函数地址可以直接写死NX开启不能执行栈上代码但 ret2text 本来就跳去代码段RELROPartial暂时无关不需要改 GOT 表很多新手看到 NX enabled 就开始慌以为要绕 NX实际上只要利用路径是跳到程序已有代码NX 完全不影响。真正影响 ret2text 的就是 Canary 和 PIE二者有任何一个开启都得换思路要么先泄露再绕过要么改用 ret2libc、ret2csu 这些更进阶的手法。这道题两者都关属于最舒服的配置出题人就是来送分的。2. 原理拆解为什么 Ret2Text 是入门必修课2.1 函数栈帧与返回地址栈溢出的本质是控制流劫持想理解 ret2text得先搞懂函数调用时栈上发生了什么事。x86-64 下一个函数被调用时call 指令会把下一条指令的地址压栈这就是返回地址函数开头通常会保存调用者的 rbp 指针再把 rsp 往下挪一段空间给局部变量用。栈上的内存布局从高地址到低地址大概长这样高地址 ------------------ | 调用者的局部变量 | ------------------ | 返回地址 | - rbp8 ------------------ | 保存的 rbp | - rbp ------------------ | 局部变量区缓冲区| - rbp - 0xXX ------------------ | ... | 低地址如果输入函数往缓冲区里写数据时不检查长度数据就从低地址往高地址一路填过去先是填满缓冲区然后覆盖保存的 rbp最后覆盖到返回地址。函数执行到 ret 指令时CPU 会把栈顶的返回地址弹出跳去执行。我们塞进返回地址位置的那个值就是程序下一步要去的地方。控制流劫持的思路就这么建立起来了往返回地址的位置放一个我们希望的地址程序退休时就会乖乖跳过去。这和快递填错收件人包裹就送到了别人手里很像栈就是我们写的快递单返回地址就是收件人字段覆盖它就等于篡改了投递目的地。2.2 ret2text、ret2shellcode、ret2libc 的分工栈溢出利用手法里有几个经常被放在一起比较的概念ret2shellcode、ret2text、ret2libc。三者最终目的都是劫持控制流但目标位置完全不同。手法跳去哪核心前提适用场景ret2shellcode栈上我们布置的 shellcodeNX 关闭程序没开 NX允许栈执行ret2text程序自带的代码段后门函数程序里恰好有危险函数出题人留了后门最省事ret2libclibc 里的 system 等函数知道 libc 版本或能泄露地址程序没后门NX 又开启ret2shellcode 依赖栈可执行现在大多数题目 NX 都开着这条路走不通ret2libc 要对抗 ASLR往往得先泄露地址复杂度高不少ret2text 是三者里最省心的因为它连地址都不用猜PIE 关了只要程序代码里有个能用的后门函数拼一个 payload 就能打通。说白了ret2text 是 CTF pwn 新手的第一道分水岭它帮你建立覆盖返回地址 跳转到指定地址这两个核心肌肉记忆。有了这个基础后面理解 ret2csu、ROP 链这些东西会更顺滑。2.3 后门函数出题人藏好的捷径为什么叫后门函数因为正常程序逻辑里根本不该调用它但出题人写代码时故意留下了这么一段可能是用来调试的也可能是题目设计的彩蛋。常见形态有两种一种是比较隐蔽的函数名奇怪比如叫 system_hack、get_flag、my_func函数内部直接调用 system(/bin/sh) 或者把 flag 文件读出来打印。另一种是函数内部看起来啥都没干但地址上藏了特殊指令序列比如刚进入函数就是 execve(/bin/sh, 0, 0) 的 syscall。对于新手来说找到后门函数最直接的方法是看字符串和函数名。反汇编后如果看到一个函数地址指向的代码片段里出现 /bin/sh 或者 cat flag 这类字符串基本可以确定它就是我们要找的目标。这道题里的后门函数藏在正常代码之外反编译后能看到它调用 system 函数参数指向一个 /bin/sh 字符串。只要我们能跳到这个函数开头程序就会交出交互 shell然后在里面执行 cat flag 就能拿到题目 flag。3. 从反汇编到一发入魂完整解题实操3.1 反汇编定位后门函数与漏洞点正式开始分析。我习惯先用 objdump 扫一遍符号表快速确认有没有可疑函数。$ objdump -t ./pwn036 | grep -E F .text在输出里除了正常的 main、_start 之类能看到一个不在常规清单里的函数名比如 backdoor。接下来反汇编这个函数确认它到底干了什么$ objdump -d ./pwn036 | grep -A 20 backdoor:反汇编输出大概长这样0000000000400792 backdoor: 400792: push rbp 400793: mov rbp,rsp 400796: mov edi,0x4008d4 40079b: call 0x4004d0 systemplt ...地址 0x400796 把 0x4008d4 这个参数放到 edi 寄存器里然后调用 systemplt。0x4008d4 大概率就是 /bin/sh 字符串的地址。验证一下$ strings -t x ./pwn036 4008d4 /bin/sh信息对上了。后门函数地址是 0x400792它做的事情等效于 system(/bin/sh)跳进去就能拿到 shell。这个函数通常不会出现在正常的 main 调用链里所以反汇编的时候要多留个心眼凡是 main 里没调用过的自定义函数都值得点进去看一眼。同时也反汇编 main 里负责接收输入的那个函数看看漏洞点长什么样vuln: 40069a: push rbp 40069b: mov rbp,rsp ... 4006a7: sub rsp,0x20 4006ab: lea rax,[rbp-0x20] 4006af: mov rdi,rax 4006b2: call 0x4004e0 getsplt ...这里有两个关键信息函数开头 sub rsp, 0x20给局部变量腾了 32 字节空间gets 读入时目标地址是 rbp-0x20也就是缓冲区起点。gets 不检查长度漏洞确认无疑。3.2 精确计算偏移量从原理到命令搞定目标地址接下来要算一件事从缓冲区起始位置到返回地址中间到底要填多少个字节的 padding。结合反汇编里的 sub rsp, 0x20缓冲区起点在 rbp-0x20。栈上从缓冲区起点到返回地址的布局是缓冲区 rbp-0x20 到 rbp-0x01共 0x20 字节 保存的 rbp rbp 到 rbp0x07共 8 字节 返回地址 rbp0x08 开始所以偏移量 0x20 8 0x28也就是 40 字节。低地址侧的 0x20 字节填充缓冲区再填充 8 字节覆盖掉保存的 rbp第 41 到 48 字节就正好落在返回地址上。不过手工算偏移的前提是反汇编信息完整、自己能看明白栈布局。更稳妥的方式是用工具实测毕竟有些代码为了对齐和优化实际栈布局可能比反汇编读出来的复杂。pwntools 里的 cyclic 就是干这个的$ cyclic 200 aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa...把这个 pattern 作为输入发给程序程序崩溃后用 gdb 看崩溃时的 RIP$ gdb ./pwn036 -q gdb-peda$ run (cyclic 200) ... RIP: 0x6161616c (laaa)RIP 显示的值是 laaa对应 pattern 里的那 4 个字符。然后用 cyclic 反查偏移$ cyclic -l 0x6161616c 40同样是 40。两套方法互相印证偏移量确认就是 0x28。这里也建议新手把两种方式都跑一遍形成习惯之后出题环境再复杂也能快速验证。3.3 构造 payloadPadding、地址与字节序偏移量有了目标地址有了payload 的结构就是三块拼起来payload padding字节 覆盖rbp字节 返回地址 payload A * 0x20 B * 0x08 p64(0x400792)第一个 0x20 字节填缓冲区第二个 8 字节填掉保存的 rbp最后 8 字节是覆写后的返回地址。注意 p64 函数会把 0x400792 转成 64 位小端字节序也就是 \x92\x07\x40\x00\x00\x00\x00\x00。小端序意味着低位字节在前这是 x86 架构的基本规则写脚本时只要有 p64 在就不用手动处理但心里得清楚这件事。实际发送时还有两个容易忽略的细节第一gets 读到换行符才停止。payload 里如果包含 0x0a 这个字节就会被当成输入结束后面全被截断。0x400792 的原始字节不含 0x0a所以可以用 gets 正常传输。第二gets 会保留 0x00 字节所以地址里的高位 0x00 不会被截断。但如果题目漏洞点是 strcpy那遇到 0x00 就麻烦了地址高位有 0x00 直接写不进去得想办法换利用路径。这道题用 gets没有这个顾虑。3.4 完整 Exp 脚本本地验证与远程一把梭到这里利用思路闭环了。完整脚本用 pwntools 写出来核心就一个 sendline 加一个 interactive。我在本地先用 process 打通确保逻辑没问题再切远程打实际目标。from pwn import * context.log_level info context.arch amd64 # 本地调试 elf ELF(./pwn036) backdoor 0x400792 # 后门函数地址 payload bA * 0x28 p64(backdoor) if args.REMOTE: p remote(题目给的地址, 10086) else: p process(./pwn036) p.recvuntil(binput your message:) p.sendline(payload) p.interactive()逐段解释一下context.log_level 设为 infopwntools 会打印收发数据的摘要调试时能看到 payload 到底发出去没有elf ELF(./pwn036) 是加载 ELF 信息之后可以用 elf.symbols[backdoor] 代替手工填地址省得每次反编译查payload 里 bA * 0x28 对应 40 字节填充p64(backdoor) 是目标地址。本地跑通之后把 REMOTE 参数加上就能一键打远程这是 pwntools 的常规操作$ python3 exp.py [] Starting local process ./pwn036: pid 12345 [] Receiving all data: Done [*] Switching to interactive mode $ cat flag flag{test_flag}看到$提示符就说明拿到了 shell输入 cat flagflag 直接打印出来。整个流程走完归到本质就三件事找到后门地址算出偏移量用 payload 覆盖返回地址。3.5 为什么这道题能一发入魂稳定性的逻辑可能有朋友会问如果开了 ASLR后门函数地址不是会随机变化吗这里需要解释清楚。checksec 显示的 No PIE 意味着 ELF 文件本身的代码段基址固定不管 ASLR 怎么开代码段里的函数地址都是常数。所以这道题里 0x400792 永远有效。ASLR 随机化的是栈、堆、libc 这些映射区域的基址后门函数在代码段靠的是 ELF 加载器固定映射不受影响。这也是 ret2text 用作入门题的重要原因流程短、变量少、结果确定。不依赖任何泄露一次 payload 必中很适合用来建立信心。等以后遇到 PIE 开启的题地址就得靠泄露来算那时候 ret2text 就不够用了得换 ret2libc 和 DynELF 那套东西。4. 翻车现场汇总常见问题与排查技巧实录4.1 偏移量差一点程序老段错误症状payload 发过去程序直接崩溃或者 ROP 执行到一半就挂。最常见的偏移量翻车有两种一是手工计算时忘了算上保存的 rbp。缓冲区到 rbp 的距离算对了但没 8导致返回地址写到了 rbp 的上一个位置ret 时 CPU 弹出的是我们填充的 rbp 垃圾数据。二是缓冲区起点判断错了比如反汇编里看到 lea rax, [rbp-0x20]以为偏移就是 0x20实际应该是 0x20 8 0x28。排查方法用 cyclic 实测一遍别省这一步。我把 cyclic -l 返回的结果和手工算的结果对不上时会优先信 cyclic然后回去重新看反汇编研究是哪里漏了。4.2 gets 输入被截断坏字符问题症状payload 里的地址带了 0x0aread 或 gets 读到这个字节就认为一行结束后半段地址根本没写进去。比如后门函数地址是 0x400a0bp64 之后包含字节 0x0agets 输入到换行符就停了地址被腰斩。这不是逻辑问题是坏字符问题。处理思路三条路一是找同功能替代地址比如函数内部某个 ret 指令作为跳板先进函数再说二是换输入方式如果程序里同时有 read 调用可以用 read 读指定长度它能容忍 0x0a三是改用 write 泄露后二次利用不过那已经是进阶路线了。常规题目一般不会刁难到这个程度但实战中遇到过值得留意。4.3 64 位下的栈对齐system 莫名段错误症状payload 完全正确地址没问题偏移没问题本地一跑直接 SIGSEGV但 gdb 里打断点看寄存器又都正常。64 位下调用 system 时如果栈没有按 16 字节对齐system 内部某些指令比如 movaps会触发段错误。这是 64 位利用的经典陷阱。很多时候差一个 ret 就全好了。套路是在 payload 里加一个 ret gadget让栈多弹一层ret 0x4004d6 # 反汇编找到的 ret 指令地址 payload bA * 0x28 p64(ret) p64(backdoor)这样 CPU 先执行一次 retrsp 前移 8 字节栈对齐恢复再跳进后门函数就稳了。这道题如果直接跳后门函数没出问题可以暂时不管但做 ret2libc 时遇到莫名崩溃第一个怀疑对象就该是栈对齐。4.4 本地打通远程不通环境不一样症状exp 在本地一打就出 shell远程一打就崩或者没反应。远程和本地不通的原因分三类第一类是远程程序的保护和本地附件不一致比如远程开了 PIE 或 Canary本地这个附件没开。这属于出题方配置问题只能换思路或者找题目自带提示。第二类是交互逻辑不一致远程有题目前缀提示recvuntil 匹配不上。pwntools 的 recv 卡住会表现为脚本挂起看不到报错。处理办法是把 context.log_level 调成 debug看实际收到的字节长什么样再调整匹配字符串。第三类是 libc 版本不一致但这道题用 ret2text 不碰 libc 地址不涉及这个问题。如果打 system 传入的是固定参数地址那确实要考虑远程 /bin/sh 字符串地址是否一致实际上是题内固定的问题不大。4.5 调试三板斧gdb、cyclic 和断点定位最后分享一套我调试栈溢出题惯用的三板斧。第一板斧gdb 挂上后先用 disas 看函数开头确认栈帧大小和缓冲区位置这一步是手工算偏移的基础。第二板斧用 pwntools 的 cyclic 生成 pattern配合 gdb 看崩溃 RIP然后 cyclic -l 反查偏移。只要偏移算错这一招十秒定位。第三板斧在调用后门函数的 ret 指令处下断点单步执行一步观察 RIP 是否跳到了目标地址。如果跳对了还崩溃检查栈对齐和参数传递如果没跳对回头检查 payload 长度和地址字节序。这三个动作能覆盖绝大多数payload 为什么没生效的排查场景。做多了以后你会发现栈溢出本身的原理并不复杂大部分时间其实花在算对偏移和查清环境这两件事上。拿到这道题的 flag 之后如果还想进一步练可以在原题基础上自己改一改给它开个 Canary 试试或者开个 PIE 试试看看原来的 payload 会怎么失效再研究绕过方案。我的实际经验是把一道简单题往难里改三遍比连刷十道难度相近的题收获更大。pwn 这东西动手踩坑一次胜过看十篇教程。