1. 从一道题开始:理解ret2text的本质
最近在CTFHub的技能树里刷题,又碰到了经典的ret2text。这玩意儿可以说是二进制漏洞利用的“Hello World”,但每次重新审视,都能发现一些新的细节。很多刚入门PWN的同学,一看到ret2text就觉得简单,不就是覆盖返回地址跳转到后门函数嘛。但真上手去写exp的时候,常常会卡在偏移计算、栈平衡或者payload构造上。这道题本身不复杂,但它像一把钥匙,能帮你打开理解栈溢出、函数调用约定和程序控制流劫持的大门。今天,我就结合CTFHub上的这道典型题目,把ret2text里里外外、前前后后那些容易被忽略的“坑”和“技巧”掰开揉碎了讲清楚。无论你是刚接触PWN的新手,还是想巩固基础的老手,相信都能有点收获。
所谓ret2text,指的是“Return to .text”,即通过栈溢出覆盖函数的返回地址,使其指向程序本身代码段(.text section)中已经存在的、对我们有利的代码片段,比如一个直接调用system("/bin/sh")的后门函数,或者一系列精心拼接的gadget。它的前提是程序存在栈溢出漏洞,并且没有开启栈不可执行(NX)保护,或者我们跳转的目标本身就是可执行的代码。CTFHub这道题就是一个非常标准的教学案例,漏洞明显,后门清晰,非常适合用来建立完整的利用思路。
2. 题目环境搭建与初步分析
拿到题目,第一步永远不是急着写exp,而是搭建好分析环境。我习惯用Ubuntu系列的系统,配套工具比较全。你需要准备以下工具:
- checksec:用于检查程序开启了哪些安全保护机制。
- file:查看程序是32位还是64位,这直接影响栈帧结构和利用方式。
- objdump或IDA Pro/Ghidra:静态反汇编分析,找到漏洞点和目标函数。
- gdb配合pwndbg/peda/gef:动态调试,精准计算偏移,观察栈布局。
- python3与pwntools:编写利用脚本的神器。
首先,用file命令看一下程序基本信息。通常CTFHub的题目会给一个可执行文件,比如ret2text。执行file ret2text,输出可能会显示“ELF 32-bit LSB executable”或者“ELF 64-bit LSB executable”。32位和64位的利用在细节上有所不同,主要体现在参数传递(寄存器 vs 栈)和地址长度上,我们先以更常见的32位为例。
接着用checksec检查保护:
checksec --file=ret2text你可能会看到类似下面的输出:
Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)这里的关键信息是:
- Stack: No canary found:没有栈溢出保护金丝雀,我们可以放心地溢出覆盖返回地址。
- NX: NX enabled:栈不可执行。但这不影响
ret2text,因为我们跳回的是代码段(.text),而不是栈上的shellcode。 - PIE: No PIE:程序基地址不随机化。这意味着代码段的地址是固定的,我们静态分析找到的后门函数地址在运行时不会变。
然后,把程序拖进IDA Pro。F5反编译主函数,是标准流程。你会看到一个非常清晰的main函数,里面调用了某个危险函数,比如vulnerable_function()或者直接就是一个用了不安全函数gets或scanf的循环。
双击进入这个危险函数,漏洞一目了然。例如:
char s[100]; // 或者 buf[0x70] gets(s); // 或者 read(0, s, 0x200)这里定义了一个局部字符数组s,长度有限,但gets函数会无限制地读取输入,直到遇到换行符或EOF,这就造成了经典的栈缓冲区溢出。我们需要溢出的数据,覆盖掉s数组本身、可能存在的栈帧指针(EBP),最终覆盖掉函数的返回地址(EIP)。
3. 核心漏洞点定位与偏移计算
找到了漏洞函数,下一步就是精确计算从我们输入的缓冲区起始位置,到返回地址存储位置之间的偏移量。这个偏移量是payload构造的基石,算错了,一切免谈。
方法一:静态分析计算在IDA的栈视图里,可以看到局部变量的布局。比如s的地址是[ebp-0x70],而保存的ebp在[ebp],返回地址在[ebp+0x4]。那么从s的起始位置到返回地址的偏移就是:0x70 (s到ebp的距离) + 0x4 (ebp本身的4字节) = 0x74,即十进制116字节。这是32位下的情况。64位下,rbp是8字节,返回地址也是8字节,计算方式类似,但要注意对齐。
方法二:动态调试验证(推荐)静态计算可能因为编译器优化、对齐等因素有细微出入,动态调试更可靠。用gdb打开程序:
gdb ./ret2text在危险函数(如gets)调用之后、函数返回(ret)之前的位置下断点。运行程序,发送一串有规律的非重复字符作为输入。我最喜欢用pwntools的cyclic工具生成。 在gdb里,你可以用r <<< $(python3 -c “from pwn import *; print(cyclic(200))”)来运行并输入。当程序崩溃时,查看EIP/RIP寄存器的值。这个值会被我们的pattern覆盖。假设EIP的值是0x6161616c(‘laaa’),然后用cyclic -l 0x6161616c命令,就能算出这个值在pattern中的偏移位置。这个偏移就是我们要的精确偏移量。务必确保静态计算和动态调试的结果一致,如果不一致,以动态调试为准。
注意:这里有一个常见的坑。如果程序本身有
printf之类的输出函数,可能会在你输入之后、崩溃之前打印一些内容,这有可能截断或影响你的pattern输入。稳妥的做法是写一个简单的pwntools脚本,通过管道(process)与程序交互,确保输入完整。
4. 寻找与利用目标:后门函数
ret2text的核心在于“text”,也就是程序代码段里现成的可利用代码。在IDA中,按下Shift+F12打开字符串窗口,搜索“/bin/sh”、“cat flag”、“system”等关键字符串。如果找到了,比如有一个字符串/bin/sh,就右键点击它,选择“Jump to xref”,找到引用这个字符串的地方。
通常,你会看到一个函数,里面调用了system("/bin/sh")。这个函数可能就是shell()、get_flag()或者hack()。记下它的起始地址,比如0x8048586。这个地址就是我们最终要让程序跳转过去的目标地址。
有时候,题目不会这么直白。可能没有直接的system("/bin/sh"),但是有system函数的PLT表地址和“sh”字符串的地址。这就需要我们进行简单的ROP链构造,但依然属于ret2text的范畴(因为跳转的目标都在.text段)。对于最基础的ret2text题目,通常就是一个现成的后门函数。
重要检查:确认后门函数本身没有问题。有些后门函数可能内部有某些判断条件,比如需要某个全局变量为特定值,或者函数开头有push ebp; mov ebp, esp这样的栈帧操作。你需要确保跳过去执行时,栈是平衡的,或者不会因为栈帧问题导致崩溃。最简单的检查方法就是在IDA里模拟一下执行流程,或者直接gdb调试跳过去看看。
5. 利用脚本编写与细节打磨
偏移有了,目标地址有了,就可以构造payload了。使用pwntools能极大简化这个过程。一个最基础的利用脚本骨架如下:
from pwn import * context(os='linux', arch='i386', log_level='debug') # 设置上下文,32位 # context(os='linux', arch='amd64', log_level='debug') # 如果是64位 p = process('./ret2text') # 本地运行 # p = remote('challenge.ctfhub.com', 10000) # 远程连接 offset = 116 # 计算出的偏移量 backdoor_addr = 0x8048586 # 后门函数地址 payload = b'A' * offset + p32(backdoor_addr) # 32位用p32打包地址 # payload = b'A' * offset + p64(backdoor_addr) # 64位用p64 p.sendlineafter(b'something:', payload) # 根据实际交互提示发送 # 或者 p.sendline(payload) p.interactive() # 拿到shell后交互看起来很简单,对吧?但实际编写和运行中,你会遇到几个必须处理的细节:
1. 栈对齐问题(64位尤其突出)在64位Linux下,system函数要求栈指针rsp在调用时必须是16字节对齐的。而ret指令会跳转到我们给的地址,此时rsp可能并不对齐。常见的解决方法是在payload中,在目标地址前多加一个ret指令的gadget地址。这个gadget只做一件事:ret。它的效果是让rsp再加8,从而调整对齐状态。所以64位下的payload可能长这样:payload = b'A'*offset + p64(pop_rdi_ret) + p64(bin_sh_addr) + p64(ret_gadget) + p64(system_plt)。你需要用ROPgadget或ropper工具在二进制文件中搜索ret。
2. 输入处理与截断如果程序使用gets,那万事大吉,它读到换行符\n(0x0a)停止。但如果程序使用scanf(“%s”),它会在空格或换行处停止。而read函数则严格读取指定字节数。如果你的payload里不小心包含了0x0a或0x20(空格),可能会被提前截断。确保你发送的地址字节里不包含这些敏感字节。如果不幸包含了,可以考虑调整溢出点,或者寻找另一个不包含坏字节的等价地址(比如后门函数内部偏移几个字节)。
3. 地址中的空字节(Null Byte)在32位系统中,地址如0x08048586,最高位是0x08,不是零。但如果地址是0x8040000,打包成p32后是\x00\x00\x04\x80(小端序),开头就是空字节。strcpy、gets等函数遇到空字节会认为字符串结束,导致payload被截断。这种情况下,ret2text可能无法直接使用,需要考虑其他方法,或者寻找更高位的地址。好在CTFHub的基础题通常不会在这里设坑。
4. 动态调试脚本在开发exp时,我强烈建议结合gdb进行动态调试。pwntools可以很方便地附加调试器:
p = process('./ret2text') gdb.attach(p, ''' b *0x80484xx (在危险函数返回处下断点) c ''')这样,发送payload后,程序会自动断住,你可以查看栈布局、寄存器值,确认返回地址是否被正确覆盖为后门地址。
6. 完整利用流程实战演示
让我们串联起整个流程,假设程序是32位,后门函数地址是0x8048586,偏移是116。
步骤1:信息收集
$ file ret2text ret2text: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=..., not stripped $ checksec --file=ret2text Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)很好,32位,无栈保护,无PIE。
步骤2:静态分析IDA打开,找到main->vuln函数。发现char buf[0x70]; gets(buf);。栈结构:buf在ebp-0x70,返回地址在ebp+4。偏移 = 0x70 + 4 = 0x74 = 116。 在字符串窗口找到/bin/sh,交叉引用找到函数shell,地址0x8048586。
步骤3:动态验证偏移编写一个简单的测试脚本test_offset.py:
from pwn import * context(log_level='debug') p = process('./ret2text') payload = cyclic(200) p.sendlineafter(b'input:', payload) p.wait() core = p.corefile print(“EIP overwritten with:”, hex(core.eip)) offset = cyclic_find(core.eip) print(“Exact offset is:”, offset)运行,确认偏移是116。
步骤4:编写最终exp
#!/usr/bin/env python3 from pwn import * context(os='linux', arch='i386') # p = process('./ret2text') p = remote('xxx.xxx.xxx.xxx', 10000) # 替换为实际题目地址 offset = 116 shell_addr = 0x8048586 payload = b'A' * offset + p32(shell_addr) p.sendlineafter(b'input:', payload) p.interactive()步骤5:运行与获取flag运行脚本,如果一切正常,你会看到一个$或者#提示符,输入cat flag或ls; cat flag等命令即可拿到flag。
7. 常见问题排查与解决思路
即使按照步骤来,也可能会遇到问题。这里记录几个我踩过的坑和解决方法:
问题1:Segmentation fault (core dumped),但偏移计算应该没错。
- 可能原因1:栈平衡破坏。后门函数开头有
push ebp; mov ebp, esp,结尾有leave; ret。leave指令相当于mov esp, ebp; pop ebp。如果我们覆盖了栈上的旧ebp值为一个非法地址,当后门函数执行leave时,pop ebp会把这个非法值装入ebp,可能影响不大。但如果在后门函数返回时,ebp被用于其他操作就可能出错。解决:在payload中,不仅覆盖返回地址,也覆盖ebp为一个可读写的安全地址(比如.bss段地址),或者确保覆盖的ebp值不会引起访问异常。通常填充为0xdeadbeef这样的占位符也可以,因为很多简单的后门函数根本不使用ebp。 - 可能原因2:环境问题。本地运行成功,远程失败。可能是libc版本差异、系统调用差异。确保远程题目提供的libc版本与本地一致,或者使用题目提供的libc。对于基础
ret2text,通常不涉及libc调用,但如果有system,其内部会涉及。解决:使用ret2libc的通用方法,或者确保目标地址是PLT表中的system地址。
问题2:成功跳转到后门函数,但没弹出shell,程序正常退出或卡住。
- 可能原因:后门函数逻辑有误。仔细反编译后门函数。也许它调用的不是
system(“/bin/sh”),而是execve或其他函数。也许它先执行了某些清理操作。用gdb单步跟进去,看看执行流和参数是否正确。 - 可能原因:输入输出流被关闭或重定向。有些题目会在主程序里关闭标准输入输出。后门函数虽然执行了
system(“/bin/sh”),但shell无法与我们交互。解决:在payload中,在跳转到后门之前,先通过ROP链调用dup2将标准输入输出重定向到socket描述符(通常是4)。但这已经超出了基础ret2text的范围。
问题3:使用pwntools的sendline发送payload后,程序没反应。
- 可能原因:交互提示不匹配。
sendlineafter(b'input:', payload)中的b'input:'必须与程序打印的提示符完全一致,包括空格和换行。最好用p.recvuntil(b'input: ')来接收,然后用p.send(payload)发送。 - 可能原因:缓冲区问题。程序可能使用了
setbuf关闭了缓冲区,或者需要手动刷新。尝试在发送payload后加一个p.recv()接收一些输出,或者使用p.sendline(payload)后跟sleep(0.1)。
问题排查速查表:
| 现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 覆盖EIP后程序立即崩溃 | 偏移计算错误 | 动态调试,用cyclic pattern精确计算 |
| 跳转后门函数后崩溃 | 栈不平衡/EBP被破坏 | 检查后门函数头尾,在payload中填充安全的EBP值 |
| 跳转后没得到shell | 后门函数逻辑非预期/流被关闭 | 静态分析后门函数,gdb跟踪执行;检查文件描述符 |
| 远程exploit失败 | 环境差异/网络问题 | 对比本地远程libc,检查地址是否随机化(PIE),网络是否稳定 |
| payload发送后无响应 | 交互字符串不匹配/缓冲区 | 精确匹配提示符,尝试添加p.recv()或sleep |
8. 从ret2text到更广阔的漏洞利用
掌握了基础的ret2text,你其实已经拿到了PWN世界的入场券。它教会了你几个最核心的概念:
- 控制流劫持:通过溢出覆盖返回地址,可以指挥CPU去执行任何你想要的代码(在内存可执行的前提下)。
- 地址计算:精确计算偏移是成功利用的前提。
- 工具链使用:checksec、IDA、gdb、pwntools这一套组合拳。
在此基础上,你可以自然过渡到其他更复杂的技巧:
- Ret2shellcode:如果程序关闭了NX(栈可执行),你可以把一段机器码(shellcode)放在栈上,然后让返回地址跳转到栈上执行它。
- Ret2libc:当程序没有现成后门,但可以泄露libc函数地址时,通过计算偏移,调用libc中的
system函数。 - ROP(Return-Oriented Programming):当NX开启,且没有直接可用的后门时,通过串联程序本身代码段中的一个个以
ret结尾的小片段(gadget),像搭积木一样完成复杂的操作(如给函数传参、调用系统调用等)。
CTFHub技能树里ret2text之后的题目,比如ret2shellcode、ret2libc、rop,都是沿着这条路逐步深入的。理解每一步的原理,比单纯抄写exp重要得多。我个人的习惯是,每做一道题,不仅要写出能打通的exp,还要画一画栈布局的变化图,理清楚每一步执行后,esp、ebp、eip寄存器以及栈上数据是怎么变化的。这个过程很慢,但积累下来,你对程序运行的理解会深刻很多。
最后,一个小技巧分享:在编写pwntools脚本时,善用context.log_level = ‘debug’。它会打印出所有发送和接收的数据,对于调试交互过程非常有用。当然,正式脚本记得关掉,不然输出太乱。另外,对于需要多次尝试的题目,可以把偏移计算、地址查找等步骤也写成脚本的一部分,实现半自动化,提高效率。