1. 从“Hello World”到“Segmentation Fault”初识缓冲区溢出大家好我是老张一个在安全领域摸爬滚打了十来年的老码农。今天我们不聊那些高大上的零日漏洞也不讲复杂的APT攻击链就从一个最经典、最基础但也最“致命”的漏洞说起——缓冲区溢出。你可能在无数安全文章里听过它的大名但总觉得它像隔着一层毛玻璃原理懂个大概真要自己动手去“黑”一下又不知从何下手。别担心今天我们就用CMU卡内基梅隆大学计算机系统安全课程的经典实验——AttackLab来一次彻彻底底的实战。我会带你从零开始在Linux环境下用最朴素的工具gdb, objdump像侦探一样分析程序的反汇编代码一步步构造攻击字符串亲手完成五次难度递增的“入侵”。这个过程比你读十篇原理文章都管用。我们先来打个比方。想象一下你有一个能装8杯水的水壶缓冲区但你非要把10杯水输入数据往里倒。多出来的那两杯水会怎么样没错它会溢出来流到桌子上弄湿你放在旁边的书和文件栈上的其他数据比如函数的返回地址。缓冲区溢出攻击干的就是这个“坏事”通过向程序输入超出其预期长度的数据让多出来的数据覆盖掉内存中某些关键信息从而劫持程序的执行流程让它去执行我们想让它干的“坏事”。AttackLab实验就是这个原理的完美沙箱。它提供了两个目标程序ctarget和rtarget。前三个阶段我们用ctarget来玩转经典的代码注入攻击就像直接把一段恶意指令塞进溢出的水里后两个阶段我们面对rtarget它开启了栈不可执行等现代防御机制这时我们就得用上更精巧的ROP面向返回编程攻击像玩拼图一样从程序现有的代码片段里“借”指令来达成目的。实验的目标很明确通过精心构造的输入字符串让原本人畜无害的getbuf()函数在读取输入后不是正常返回而是跳转到特定的touch1,touch2,touch3函数并满足相应的条件比如传递正确的参数。每成功一次就像游戏通关一样程序会打印出“Touch!: You called touchX”的胜利宣言。下面就让我们挽起袖子开始这场有趣的“破解”之旅吧。2. 磨刀不误砍柴工实验环境与工具准备工欲善其事必先利其器。在开始“攻击”之前我们得先把战场布置好。别被“攻击”这个词吓到我们是在完全可控的、合法的实验环境里进行学习目的是理解漏洞原理从而写出更安全的代码。首先你需要一个Linux环境。我强烈推荐使用64位的Ubuntu 18.04或20.04 LTS系统稳定软件包齐全。你可以在实体机上安装也可以用虚拟机如VirtualBox或VMware甚至Windows的WSL2也完全没问题。我个人的习惯是在虚拟机里做这些实验方便随时快照和回滚。实验的核心程序ctarget和rtarget以及配套的farm.c、cookie.txt等文件你需要从CMU的课程网站获取或者在一些开源的学习仓库里也能找到。确保你下载的实验包是完整的。接下来是三大神器的安装与熟悉gdb (GNU Debugger)我们的“显微镜”和“时间控制器”。用它我们可以深入程序内部查看任意时刻的内存状态、寄存器值单步执行每一条汇编指令。安装命令很简单sudo apt-get install gdb。我建议再装个增强版pwndbg或gef界面更友好功能更强大。objdump我们的“反编译镜”。它能把编译好的二进制程序如ctarget反汇编成人类可读的汇编代码是我们分析程序逻辑、寻找关键地址的必备工具。它通常随binutils包一起安装系统一般自带。hex2raw这是一个实验包自带的小工具。因为我们的输入需要包含不可打印字符比如内存地址所以我们先用文本编辑器编写十六进制表示的“攻击代码”再用hex2raw转换成原始的二进制字节流最后喂给目标程序。它的用法我们后面会详细讲。最后调整一下心态。这个过程可能会遇到很多“Segmentation fault (核心已转储)”这太正常了每一个段错误都是一次宝贵的调试机会。别怕出错打开gdb看看程序到底死在了哪里返回地址被我们改成了什么奇怪的值。记住我们不是在搞破坏而是在通过“创造性”的输入来验证和理解计算机系统底层的工作机制。这种亲手把程序“掰弯”的经历会让你对内存、栈、指令执行有刻骨铭心的认识。3. 阶段一第一次“转向”——覆盖返回地址好了热身结束让我们正式进入AttackLab的第一关。这一关的目标最简单让getbuf()函数执行完毕后不返回到调用它的test()函数而是跳转到touch1()函数。我们先来看看getbuf()在C语言层面长什么样unsigned getbuf() { char buf[BUFFER_SIZE]; Gets(buf); return 1; }这个Gets()函数和C标准库里那个臭名昭著的gets()一样它从不检查输入的长度只会一股脑地读直到遇到换行符或文件结束符。buf就是我们说的那个“水壶”它的容量BUFFER_SIZE是固定的。我们的任务就是找到这个水壶到底有多大然后灌入刚好能淹没它的水让多出来的水精确地覆盖掉“返回地址”这本书。第一步侦察。用objdump把ctarget反汇编objdump -d ctarget ctarget.s打开ctarget.s文件找到getbuf函数。你会看到类似下面的汇编代码地址可能不同000000000040186a getbuf: 40186a: 48 83 ec 38 sub $0x38,%rsp 40186e: 48 89 e7 mov %rsp,%rdi 401871: e8 9a 02 00 00 callq 401b10 Gets 401876: b8 01 00 00 00 mov $0x1,%eax 40187b: 48 83 c4 38 add $0x38,%rsp 40187f: c3 retq看第一句sub $0x38,%rsp。这是在栈上为局部变量buf开辟空间。0x38是十六进制转换成十进制是56。所以buf数组的大小是56字节。同时这也告诉我们getbuf函数的栈帧大小至少是56字节。第二步定位目标。在ctarget.s里搜索touch1找到它的起始地址。假设是0x40194b。第三步理解栈布局。当getbuf被调用时调用者test会把返回地址即test中call getbuf下一条指令的地址压入栈顶。然后getbuf执行sub $0x38,%rsp将栈指针%rsp下移56字节这56字节的空间就分配给了buf。所以内存布局从高地址到低地址是test的栈帧 -返回地址-getbuf的栈帧其中包含buf[0]到buf[55]。Gets(buf)从buf的开始地址即%rsp向高地址写数据。如果我们写超过56字节多出来的数据就会覆盖高地址处的……没错就是返回地址。第四步构造攻击字符串。我们需要一个56字节的“填充物”内容任意常用0x90即NOP指令或者0x00后面紧跟着我们想要的地址——touch1的地址0x40194b。这里有个关键细节x86-64是小端字节序即低位字节在前。所以地址0x40194b在内存中应该写成4b 19 40 00 00 00 00 0064位地址共8字节高位补零。我们创建一个文本文件phase1.txt内容如下每两个十六进制数字代表一个字节/* 56字节的填充 */ 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 /* touch1的地址小端表示 */ 4b 19 40 00 00 00 00 00第五步发动攻击。使用hex2raw将文本转换成原始字节然后通过管道传递给ctarget并加上-q参数避免连接课程服务器./hex2raw phase1.txt | ./ctarget -q如果一切顺利你将看到屏幕输出 “Touch1!: You called touch1”。恭喜你你完成了人生中第一次缓冲区溢出攻击你成功改变了程序的执行流。这个过程看似简单但它揭示了最本质的原理控制数据就能控制程序。4. 阶段二注入第一行代码——传递参数攻击第一阶段我们只是让程序“拐了个弯”第二阶段我们要让它“顺便帮我们干点活”。touch2函数需要一个整数参数val并且要求val必须等于一个特定的cookie值这个值在cookie.txt文件里假设是0x55fd3f95。我们的目标是让getbuf返回后不仅跳转到touch2还要在跳转前把cookie值放到%rdi寄存器x86-64下第一个整数参数存放的寄存器里。这就不能只覆盖返回地址了我们需要注入一小段我们自己的机器指令到栈上并让程序去执行它。这听起来很黑客但原理并不复杂。第一步编写注入代码。我们需要用汇编语言写一段小程序它的功能是1) 将cookie值赋给%rdi2) 跳转到touch2。我们不能直接用jmp因为jmp的目标地址在编写注入代码时很难确定。更优雅的方式是利用ret指令它从栈顶弹出地址并跳转。所以我们的计划是把touch2的地址也放在栈上然后用ret跳过去。创建一个phase2.s汇编文件movq $0x55fd3f95, %rdi # 将cookie值放入rdi pushq $0x40194b # 将touch2地址压栈假设地址是0x40194b ret # 弹出栈顶地址即touch2地址并跳转第二步获取机器码。汇编代码需要编译成机器码才能被CPU执行。gcc -c phase2.s objdump -d phase2.o phase2.d查看phase2.d文件你会看到类似这样的机器码48 c7 c7 95 3f fd 55 mov $0x55fd3f95,%rdi 68 4b 19 40 00 pushq $0x40194b c3 retq这就是我们要注入的指令字节序列48 c7 c7 95 3f fd 55 68 4b 19 40 00 c3。第三步确定注入地址。我们需要知道这段指令会被放在栈的哪个位置以便让getbuf返回到那里去执行。打开gdb调试gdb ctarget (gdb) break getbuf (gdb) run -q程序会在getbuf入口停下。输入stepi执行几条指令直到执行完sub $0x38,%rsp。此时%rsp指向的就是buf数组的起始地址也是我们注入代码的起始地址。用p /x $rsp打印出来假设是0x55678578。记住这个地址它就是我们的“跳板”。第四步构造攻击字符串。现在我们的攻击字符串结构是[注入的机器码] [填充字节凑够56字节] [“跳板”地址]。开头放入我们的机器码48 c7 c7 95 3f fd 55 68 4b 19 40 00 c3。用任意字节比如00填充直到总长度达到56字节。最后8字节放入“跳板”地址0x55678578小端格式78 85 67 55 00 00 00 00。这样当getbuf返回时它会从栈顶弹出我们覆盖的返回地址即“跳板”地址然后跳转到buf的开始处执行我们注入的指令。这些指令会把cookie放入%rdi将touch2地址压栈然后ret指令再将其弹出并跳转完美达成目标。第五步验证攻击。同样用hex2raw和管道传递攻击字符串。看到 “Touch2!: You called touch2” 就成功了。这一步你实现了真正的代码注入攻击感觉是不是更酷了你不仅改变了流程还让CPU执行了你亲手写的指令。5. 阶段三在内存中“藏”一个字符串第三关的难度又上了一个台阶。这次要调用touch3它需要的参数是一个字符串指针指向的字符串内容必须是你的cookie值的十六进制ASCII表示比如cookie是0x55fd3f95字符串就是55fd3f95。这带来了两个新挑战我们需要把字符串55fd3f95也放到内存的某个地方。touch3内部会调用hexmatch和strncmp函数这些函数有自己的栈帧会覆盖getbuf缓冲区所在的内存区域。如果我们把字符串放在buf里很可能被后续函数调用破坏。所以我们必须给字符串找一个“安全屋”。一个常见的策略是把它放在getbuf的栈帧之上也就是比返回地址更高的地址更低的栈地址因为栈向下增长。这样hexmatch的栈帧在更低地址处开辟就不会破坏我们的字符串。第一步确定字符串内容与地址。Cookie值0x55fd3f95的ASCII字符串是35 35 66 64 33 66 39 35每个数字/字母对应其ASCII码的十六进制别忘了C语言字符串以\0结尾所以还要加一个00。字符串内容就是35 35 66 64 33 66 39 35 00。我们需要计算字符串的存放地址。回顾阶段二我们知道buf的起始地址是0x55678578。buf占56字节后面8字节是返回地址。如果我们把字符串放在返回地址的后面即更高的内存地址那么它的地址就是0x55678578 56 8 0x556785b8。当然你也可以放在其他你认为安全且能计算出的位置。第二步编写注入代码。这次注入的汇编代码很简单就是把字符串的地址赋给%rdi然后跳转到touch3。movq $0x556785b8, %rdi # 将字符串地址放入rdi pushq $0x401a62 # 将touch3地址压栈假设是0x401a62 ret同样编译反汇编得到机器码假设是48 c7 c7 b8 85 67 55 68 62 1a 40 00 c3。第三步构造攻击字符串。这次的结构稍微复杂开头注入的机器码。填充用任意字节填充确保从buf开始到返回地址之前正好56字节。第57-64字节覆盖的返回地址即“跳板”地址buf起始地址0x55678578。第65字节开始放置我们的字符串35 35 66 64 33 66 39 35 00。这样当程序执行我们注入的代码时movq $0x556785b8, %rdi就能正确指向我们预先放置好的字符串。第四步攻击与验证。生成攻击文件并运行。成功后会看到 “Touch3!: You called touch3”。这一关的关键在于对栈内存布局的精确计算。你需要像一个内存建筑师一样精心规划每一块数据的位置确保它们在程序执行过程中不会被意外破坏。这非常考验你对函数调用栈的理解深度。6. 阶段四当栈不再可执行——ROP攻击初探恭喜你已经成功闯过了代码注入的三关但从第四关开始游戏规则变了。我们面对的不再是ctarget而是rtarget。这个程序很可能开启了栈不可执行NX的保护机制。这意味着即使我们像之前一样把指令注入到栈上CPU也不会去执行它因为栈所在的内存页被标记为“不可执行”。这就像在水壶外面涂了一层特氟龙水数据可以倒进去但你没法把它当燃料烧了。那怎么办黑客们发明了ROPReturn-Oriented Programming面向返回编程。它的核心思想是我们不注入新代码而是利用程序里已经存在的代码片段gadget。这些片段通常以ret指令结尾。通过精心控制栈上的内容主要是返回地址链我们可以让程序像执行“多米诺骨牌”一样一个接一个地执行这些gadget最终拼凑出我们想要的功能。第四关的目标和阶段二一样调用touch2并传递cookie值给%rdi。但我们不能再注入movq $cookie, %rdi这样的指令了。我们需要在rtarget的代码段特别是farm.c编译后提供的gadget farm区域里寻找可用的零件。第一步寻找gadget。我们需要两个功能1) 将cookie值从栈上弹到一个寄存器2) 再把这个寄存器的值移动到%rdi。常用的模式是popq %rax; ret和movq %rax, %rdi; ret。 用objdump -d rtarget rtarget.s反汇编在start_farm和end_farm标记的区域内搜索指令字节码。例如搜索58popq %rax的编码和48 89 c7movq %rax, %rdi的编码。假设我们找到了popq %rax; ret的地址在0x401b35。movq %rax, %rdi; ret的地址在0x401b2e。第二步规划ROP链。我们的攻击字符串即栈上的布局将不再是代码而是一系列地址和数据前56字节任意填充。第57-64字节第一个gadget地址0x401b35。当getbuf的ret执行后会跳到这里。第65-72字节cookie值0x55fd3f95。这是为popq %rax准备的它执行时会从栈顶弹出这个值到%rax。第73-80字节第二个gadget地址0x401b2e。当第一个gadget的ret执行后会跳到这里。此时%rax已经是cookie值movq %rax, %rdi会将其复制到%rdi。第81-88字节touch2的地址0x40194b。第二个gadget的ret执行后最终跳转到touch2。第三步构造攻击字符串。按上述布局编写十六进制文本文件。注意所有地址和数据都要用小端格式。第四步发动ROP攻击。使用./hex2raw phase4.txt | ./rtarget -q。看到 “Touch2!: You called touch2” 时你会由衷地赞叹ROP的巧妙。它就像在用程序自身的“乐高积木”搭建攻击路径完全规避了代码注入的检测。理解ROP是理解现代漏洞利用技术的基础。7. 阶段五ROP的终极挑战——构造字符串指针这是AttackLab的终极BOSS战。目标依然是调用touch3并传递字符串指针但这次要用ROP来实现。这比阶段四复杂得多因为我们需要在无法直接向内存写入字符串的情况下“变”出一个指向有效字符串的指针。思路是我们需要在内存中某个已知且稳定的位置“构造”出这个字符串。哪里稳定呢栈本身是相对稳定的只要我们计算好地址。我们可以把字符串放在攻击字符串的某个位置比如很靠后的地方然后通过一系列gadget运算计算出这个字符串在栈上的绝对地址并将其放入%rdi。这通常需要用到能进行算术运算如加法的gadget。例如我们可能找到一个add指令的gadget。我们需要做的是将一个栈地址例如某个gadget执行时%rsp的值放入一个寄存器比如%rax。将一个固定的偏移量从该栈地址到我们放置字符串地址的距离放入另一个寄存器比如%rbx。使用addgadget 将两者相加结果即字符串地址存入%rdi。跳转到touch3。这个过程需要精心挑选多个gadget通常需要8个或更多并精确计算每一步栈指针的位置和偏移量。你需要反复查看rtarget.s中farm区域的所有gadget像玩拼图一样组合它们。可能的gadget序列包括popq %rax; ret放入偏移量常量movq %rax, %rbx; retmovq %rsp, %rax; ret获取当前栈指针add %rbx, %rax; ret最后movq %rax, %rdi; ret。构造攻击字符串时栈上会交替放置gadget地址和所需的数据常量。每一步ret都精准地将控制流导向下一个gadget同时栈指针也在不断移动。这需要极大的耐心和细致的调试。我建议你在纸上画出栈的布局图标出每一次ret之后%rsp的位置以及每个gadget会从栈上“消费”多少字节的数据。当你最终看到 “Touch3!: You called touch3” 时那种通关的成就感是无与伦比的。你不仅理解了缓冲区溢出更亲手实践了绕过现代防护机制的ROP攻击。这五个阶段走下来你对程序内存布局、控制流劫持的理解已经远超很多纸上谈兵的安全爱好者了。8. 从攻击到防御我们能学到什么走完了AttackLab的五个阶段我们像黑客一样思考并行动了一次。但实验的终极目的绝不是为了教会大家如何去攻击。恰恰相反它是为了让我们从攻击者的视角深刻理解漏洞产生的根源从而在编写代码时能本能地规避这些陷阱。首先永远不要使用不安全的函数。像gets、strcpy、sprintf这类不检查边界的老式C库函数是缓冲区溢出的罪魁祸首。在现代开发中务必使用它们的“安全”版本如fgets、strncpy、snprintf并始终确保指定目标缓冲区的大小。其次启用编译器和操作系统的保护机制。就像rtarget所做的那样栈不可执行NX/XD阻止在栈上执行代码有效防御代码注入。栈金丝雀Stack Canary在栈帧的返回地址前放置一个随机值金丝雀函数返回前检查其是否被改变若改变则终止程序。地址空间布局随机化ASLR随机化进程内存布局栈、堆、库的地址增加攻击者预测地址的难度。控制流完整性CFI更先进的机制确保程序执行流不会跳转到非预期的位置。然而正如ROP攻击所展示的没有银弹。ROP利用现有的代码片段绕过了NX。面对ASLR攻击者可能会结合信息泄露漏洞来获取地址。这引出了第三点安全的本质是减少攻击面和提高攻击成本。即使无法完全杜绝漏洞也要通过代码审计、输入验证、最小权限原则等手段让漏洞难以被利用。最后也是最重要的培养安全编程意识。理解底层原理栈帧、汇编、内存管理是写出健壮代码的基础。每次你写下一行处理用户输入的代码时AttackLab中那些精心构造的字符串应该在你脑海中闪过。问问自己“如果输入比预期长十倍、一百倍会发生什么”这个实验就像一次消防演习。我们故意在可控环境里“放火”是为了学习如何在真实世界里更好地“防火”。希望这次从零到一的实战解析能让你对计算机系统安全有一个扎实而直观的入门。当你再看到“缓冲区溢出”这个词时脑海里浮现的不再是模糊的概念而是一行行汇编代码、一个个内存地址以及那种亲手操控程序流的奇妙感觉。这才是真正学到了东西。