资讯详情

资讯详情

Windows栈溢出入门实战:从崩溃到控制EIP的完整演示

说起Windows漏洞利用栈溢出几乎是被讨论最多、也最适合入门的基础课题。哪怕你现在的兴趣早就跑到内核、浏览器沙箱这些偏门方向回头把栈溢出的原理补齐也一点不亏。这篇文章不带高深理论我会用一个小实验把从“程序崩溃”到“控制EIP”的整条链路走一遍同时把函数栈、返回地址、偏移量这些听起来绕、实际上很简单的概念讲透。适合谁读刚开始接触漏洞研究、准备打CTF或者只是想知道栈溢出到底怎么回事的读者。你需要一点C语言基础和基本的十六进制概念没有也能跟下来关键位置我会解释为什么。1. 整体设计为什么拿Windows栈溢出开刀1.1 先理解栈溢出为什么是“基本功”漏洞研究领域有个不成文的说法内存破坏类漏洞万变不离其宗而栈溢出就是最简单的那个“宗”。不管是缓冲区数据越界、格式化字符串还是后来更复杂的堆溢出和释放后使用核心都是程序没有按预期管理内存数据。先吃透栈溢出后面看其他类型会顺畅很多。选择Windows平台而不是Linux不是Windows更“好打”而是它的调用约定、模块加载方式和防护机制跟Linux差异很大。很多人用惯了Linux下的调试工具和脚本框架一拿到Windows样本就发懵参数怎么传的返回地址在哪模块基址为什么老变这篇文章用Windows环境就是想把这套差异先磨平。只要你把这个基础实验做通了以后看到Windows上的崩溃转储、调用栈回溯心里会踏实很多。1.2 实验环境选型尽量给自己省麻烦很多人初学栈溢出喜欢直接在物理机上跑这是我最不推荐的做法。正确姿势是准备一台隔离的虚拟机系统选32位Windows XP或者32位Windows 7原因后面会提。平台上装好编译环境和调试器整个过程控制在几十分钟以内。为什么强调32位因为x86的栈布局最直观函数调用时参数、返回地址、旧EBP的压栈顺序非常清晰偏移计算也容易验证。换到64位参数传递优先走寄存器栈上的变化没那么直白不适合作为第一课。虽然真实环境早以64位为主但学习顺序上x86栈溢出依然是快速建立直觉的最短路径。环境准备清单也不复杂一台Windows XP或Windows 7虚拟机一个支持C/C的编译环境一个看着顺眼的调试器。我习惯用x64dbg开源免费32位和64位程序都能调经典的OllyDbg也够用。虚拟机里还要做一件事关闭系统的随机化地址和DEP。注意这只在实验虚拟机里做不要拿真实系统试。1.3 整条实验链路怎么拆我会把实验拆成五个节点每个节点都有一个明确的可验证目标而不是闷头一把梭。第一步写一个带漏洞的教学程序漏洞来源是strcpy这类不检查长度的函数。第二步用超长输入去触发崩溃在调试器里看到返回地址被覆盖成0x41414141也就是我们填进去的大写字母A。第三步精确定位填充数据和返回地址之间的偏移。第四步把返回地址替换成程序里另一个函数的地址看到程序乖乖跳过去执行。第五步回头讨论如果继续往栈里放机器码会发生什么以及防护机制如何拦住这一切。这五步看似平淡但每一步都在验证一个独立假设。崩溃了不代表你能利用拿到EIP控制权也还要面对字节序、坏字符、栈可执行性这些细节。分步验证的好处是出问题时你能快速定位是环境问题、编译问题还是构造问题而不是整个实验黑压压一片。2. 栈溢出原理与核心知识点拆解2.1 函数调用时栈上到底发生了什么栈是程序运行时使用的一块特殊内存以“后进先出”的方式工作。它从高地址往低地址增长也就是说每次压栈栈顶地址变小。x86下有个专门的寄存器ESP指向栈顶EBP则常被用作栈底基准。当一个函数被调用时大致顺序是这样的调用方先把参数压栈然后执行call指令。call指令会立刻把call下一条指令的地址压栈这个地址叫返回地址。进入被调函数后函数先把调用方的EBP压栈保存然后让EBP指向当前栈顶最后用sub ESP, X的方式腾出一块区域存放局部变量。这一串操作下来函数内部的栈布局从高地址到低地址依次是调用方的数据、参数、返回地址、保存的旧EBP、局部变量区。我用一个简化图表示高地址 ------------------------- | 调用方局部变量等数据 | ------------------------- | 参数 | ------------------------- | 返回地址 | - 函数结束RET时从这里取地址 ------------------------- | 保存的旧EBP | ------------------------- | 局部变量区buffer... | - ESP指向低地址 ------------------------- 低地址注意返回地址是函数正常结束后跳回去继续执行的关键。函数结尾的RET指令不关心你原来是怎么进来的它只做一件事从栈顶弹出四个字节32位下当成下一条指令地址跳过去。试想如果我们在溢出过程中把这四个字节改写了RET之后就轮到我们指定的地址来执行代码了。这就是栈溢出的核心控制返回地址进而控制EIP。2.2 缓冲区溢出数据是怎么“越界”的缓冲区是程序预留的一段固定大小的内存比如char buffer[64]表示64字节。问题在于C语言里有一批很“爽快”的函数比如strcpy、sprintf、memcpy它们只负责把数据拷进去不负责检查目标空间够不够。strcpy的逻辑是“从源字符串头开始逐个拷贝直到遇到字符串结束符\0”。如果源字符串长度超过64字节多出来的部分就会顺着buffer的起始地址往后继续写一路覆盖到栈上更高地址的内容也就是保存的旧EBP、返回地址甚至更往上的参数区。生活里有个很贴切的类比你有一个固定大小的纸箱非要往里面塞一根长棍子。棍子的一端先进箱子剩下的一段只能戳破箱子壁伸到隔壁的储物格里。隔壁储物格原本放着什么并不重要重要的是棍子越界之后占据了那个位置。缓冲区溢出就是这根棍子字符串数据就是越界的部分。写漏洞代码很容易但要知道越界写发生在函数内部栈空间时它的方向是从低地址往高地址覆盖而返回值比buffer处在更高的地址所以天然会被后写入的数据砸中。2.3 偏移量从缓冲区到返回地址的距离既然要覆盖返回地址就必须知道从buffer起始位置到返回地址存储位置中间隔了多少个字节。这个距离叫偏移是整个栈溢出实验里最需要精确的数据。理论上偏移由三部分相加得到局部变量区大小、编译器插入的额外填充对齐或优化导致、保存的旧EBP字节数。以char buffer[64]为例在没有额外padding的情况下buffer占64字节保存的EBP占4字节返回地址在EBP上方再隔4字节。所以从buffer开始到返回地址理论上偏移是64872字节。但不要高兴太早编译器实际布局很可能不是教科书式的遇到Debug版本、优化选项、不同编译环境偏移可能变成76、84甚至更多。所以实验里真正可靠的做法不是猜而是用特征字符串去“量”。拿一串每个4字节都不同的数据填进去看程序崩溃时EIP上的值再到特征串里反查这个值所在的位置偏移就出来了。这个过程后面会详细演示。只要你把偏移量核实过一次就再也不会被各种教程里五花八门的数字迷惑了。2.4 为什么真实环境没那么容易成功先泼一盆冷水把上面这套流程放到现代完整防护的系统里大概率第一步就翻车因为现代系统有三个拦路虎。GS机制也叫栈保护。编译器在函数入口处往栈上放一个随机数函数返回之前检查这个随机数有没有被改动。如果缓冲区溢出碰巧覆盖了它程序直接终止根本轮不到RET你控制EIP。第二道叫DEP字面意思是数据执行保护。就算你把机器码写进栈里并成功让EIP指向栈CPU也不允许把栈内存当指令执行会直接报访问违规。第三道是ASLR系统模块每次加载的基址随机变化你辛辛苦苦算出来的跳转地址下次运行完全失效。这三道墙对初学者是巨大的劝退点但你不能因此放弃学习栈溢出。恰恰相反防护机制的存在提醒我们漏洞利用从来不是“填满缓冲区再跳转”这么简单。这篇文章的实验环境特意关闭这些机制是为了先把最基础的原理走通。至于绕过方法属于后面的进阶内容不在本文展开。研究这些技术最终目的是帮助防御方理解攻击路径而不是制作攻击武器。3. 实操从崩溃到控制流劫持的完整过程3.1 准备一个故意留漏洞的教学程序我准备了一个足够简单的示例它只有一个明显的漏洞点适合当靶子#include stdio.h #include string.h void win(void) { printf(You redirected control flow!\n); } void vulnerable(char *input) { char buffer[64]; strcpy(buffer, input); printf(buffer: %s\n, buffer); } int main(int argc, char *argv[]) { if (argc 2) { printf(Usage: vuln.exe input\n); return 1; } vulnerable(argv[1]); printf(normal exit\n); return 0; }vulnerable函数里strcpy把命令行参数拷进64字节的buffer完全没有长度检查。win函数是一个标记函数如果程序能跳到它并打印“You redirected control flow!”就说明控制流劫持成功。编译需要做两件事关闭GS保护关闭ASLR/固定基址。如果你熟悉命令行编译可以用类似cl /GS- /Od vuln.c这样的方式调试器里加载时也要确认程序加载基址固定。编译选项不同会导致偏移和数据布局不同所以下文给出的数字只是“某个环境下的实测值”真正实验时请以你自己的实测为准。这里必须强调这个程序只在隔离实验虚拟机里运行输入数据不会被用于任何真实系统。它存在的意义是让初学者以最快速度理解栈溢出的原理。3.2 用超长输入触发崩溃拿到编译好的vuln.exe第一步是让它崩溃。构造200个字符全部填入缓冲区字符串AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA在命令行里执行vuln.exe后面跟着这一长串A。正常情况下程序会输出一个超长的buffer内容然后崩溃。为什么因为字符串长度远超64strcpy一路写下去先是填满buffer接着覆盖保存的旧EBP再往上覆盖返回地址。0x41是字母A的十六进制所以返回地址被填成了0x41414141。在调试器里运行注意不要直接用命令行双击最好在调试器里配置命令行参数。加载后设置断点在main函数或vulnerable函数然后运行。程序崩溃后调试器会弹出一个“访问冲突”之类的异常提示这时查看寄存器窗口你会看到EIP的值是0x41414141。这一步看到EIP变成0x41414141是整个实验的第一个里程碑。它直接证明第一程序崩溃点确实发生在RET之后跳转第二返回地址确实可以被输入数据控制第三你已经成功干预了最核心的执行流程。3.3 精确定位返回地址的偏移量EIP变成0x41414141只是说明“返回地址被覆盖了”还不知道从哪一位开始覆盖。先看一个简单估算buffer大小是64如果编译器没有额外填充偏移可能是644(旧EBP)4(返回地址前面) 72但前面说过实际可能会偏。这时候就要用特征字符串来精确测量。特征字符串的原理是让每四个字节都不同这样当EIP显示某个四字节时你能在字符串里反查它是第几个字节。经典做法是准备一段由固定长度块组成的字符串比如AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLLMMMMNNNNOOOO...对这个例子先用64个A加4个B加4个C去试。如果偏移是教科书式的648那么保存的EBP区域是“BBBB”返回地址区域是“CCCC”崩溃时EBP显示0x42424242EIP显示0x43434343。一旦你的输入长度精准到这个程度就能从字符位置反推偏移。但实际实验常常会多出一段padding比如我碰到过偏移是76的情况。这时可以用另一种更省事的方法在调试器里给vulnerable函数下断点观察EBP和buffer首地址的差值。假设EBP0x0019FEC4buffer首地址0x0019FE74差值0x50也就是80字节那么偏移就是80484。这种静态读布局的方法比盲目试长度快得多也是我在实际排查中最常用的一招。拿到偏移后构造数据的模板就固定下来了填充偏移量字节 目标地址4字节填充部分可以是任意无意义字母目标地址注意要按小端字节序写入。3.4 把返回地址改写为程序里的目标函数现在要把返回地址改成win函数的地址。先查win函数的地址在调试器的反汇编窗口里找到win函数的起始地址。假设它位于0x00401000注意不同环境下这个值会变必须以你调试器里看到的为准。构造第二次输入先填充偏移量的字节比如按上面假设的72字节也就是18组“ABCD”然后接上win函数地址。小端序的意思是低字节在前0x00401000在内存里写作\0x00\0x10\0x40\0x00这样写出来WinHex或调试器缓冲区里看到的顺序是00 10 40 00。有一个问题马上浮现这下包含0x00字节。strcpy是基于\0判断字符串结束的如果你把地址直接接在填充后面程序会在第一个0x00处截断后面的0x10 0x40 0x00根本不会写入buffer。这是新手最容易翻车的点。处理办法也简单不要用strcpy的触发方式了改用不会因\0截断的传递方式。最省事的是修改教学程序把strcpy换成memcpy或者用文件读取方式传入数据。换成memcpy后它可以拷贝任意字节不会在\0停下。重新构造payload后会看到程序输出buffer: 一堆填充字符乱码偏移 You redirected control flow!注意没有打印“normal exit”。因为程序在main里正常输出之前就跳进了win函数而win函数执行完后RET回到栈上那块的返回地址是无效的程序会在win函数返回后再次崩溃。但这无关紧要你已经看到控制流被劫持到了目标函数实验目标达成。之后如果发生“二次崩溃”不影响结论。3.5 如果继续深入shellcode注入和跳板把返回地址改成程序内函数只是证明了控制流劫持。真正的漏洞利用里攻击者往往希望执行自己的机器码也就是shellcode。思路很直接把机器码填充在缓冲区中把返回地址改成buffer的首地址让RET之后CPU直接执行栈里的机器码。但这有几个前提栈必须是可执行的也就是DEP被关闭buffer地址必须是固定或者可预测的否则ASLR会让你找不到栈位置机器码里不能出现会被坏字符中断的字节尤其不能出现\0、\n、\r这些会被输入函数特殊处理的字符。更常见的做法是找一条“跳板指令”比如jmp esp。因为RET执行完之后ESP正好指向返回地址后面的某个栈位置如果这个位置正好是我们可控的填充区一条jmp esp就能让EIP跳回栈上执行机器码。这种手法在WinXP时代很流行。现在系统有DEP这条路基本被堵死于是才有ROP这种用系统已有指令拼凑攻击链的方法。这里我不给出具体机器码因为教学目的的边界到这里就够了。理解栈溢出原理并且能亲手复现控制流劫持已经为后续学习打下最坚实的地基。真要继续深入shellcode、ROP那是另一篇长文的容量。4. 常见问题与排查技巧实录这一章我把实际碰到过的典型翻车现场整理出来。照着做能帮你省掉至少两小时的排查时间。4.1 崩溃寄存器里的值不对表现注入超长A之后EIP不是0x41414141而是别的古怪值。原因一般有三个。一是输入长度不够根本没覆盖到返回地址程序只是因访问违规打死EIP还停留在正常位置。二是编译器做了额外优化或对齐缓冲区和返回地址之间插了好几组填充导致偏移比预想大很多。三是程序启动了GS保护栈上随机数被覆盖后函数在返回前检查不过直接调异常处理你根本走不到RET。排查优先级也很清楚先在调试器里看vulnerable函数入口处EBP和buffer首地址的差值直接量化偏移然后逐步延长输入长度观察EIP变化。如果确认是GS保护回编译环境确认GS选项是否真的关闭了。初学者喜欢一通乱试其实按顺序检查这三个点几分钟就能定位。4.2 算出来的偏移一运行就崩最经典的情况你按理论算出偏移72填72个A加4个B加4个C但EIP显示的不是预期字符。原因多半是多了没算进去的padding。以VC编译器的Debug模式为例编译器可能为了对齐把局部变量区扩大或者插入安全cookie实际距离不是简单的648。所以别依赖纸面推演直接测量才是最稳的。我建议的排查顺序是先用调试器断点确认buffer地址和EBP地址算出差值再在这个差值上加4得到偏移然后构造payload验证。如果验证时二次崩溃不用慌打开崩溃时的堆栈窗口看EIP落在什么位置就知道自己是多了四个字节还是少了四个字节。4.3 返回地址里出现截断字符表现第二次构造payload时明明算好了偏移地址也写对了但运行后程序没跳转还是一脸无辜地崩溃。十有八九是0x00截断问题。0x00在字符串里表示结束strcpy这种函数一碰到它就不再拷贝了。你的地址是00 10 40 00它写完第一个00就停了。解决办法分三路改输入方式用memcpy绕过\0终止找一个不含0x00的“等价地址”比如程序加载基址恰好让win函数地址没有0x00字节或者干脆用跳板思路跳到一个不会被坏字符问题干扰的位置。初学者建议改输入方式最直接不要把精力耗在找无00地址上。4.4 开了GS、DEP、ASLR后实验失效如果你脱离实验虚拟机跑在默认防护的Windows上实验会以各种姿势失败。GS会导致函数返回前被拦截DEP会导致即使EIP指向栈也报访问冲突ASLR会让目标地址每次加载都变化。面对这种情况初学者的正确策略不是硬怼而是先关掉它们理解原理。关闭ASLR和DEP的方式在虚拟机里可以通过系统启动配置或编译器链接选项实现。把这些变量隔离掉之后你才能看清栈溢出本身的逻辑。当你理解基础原理后再逐个打开防护去思考开启GS时能不能覆盖栈上的SEH处理函数开启DEP时能不能用ROP链去拼凑这些是进阶方向但至少你现在知道它们以什么方式挡住你。千万不要一上来就在全防护环境里折腾那不是学习是自虐。4.5 别人结果和我不同怎么排查网上教程给出的偏移、地址经常跟你的环境对不上。不要急着怀疑自己编译器版本、优化开关、系统版本、补丁版本、命令行参数的传递方式全都影响最终布局。同一个源码VC6编译结果和MinGW编译结果可能差出好多个字节。我的习惯是每换一个编译环境就重新做一遍“测量偏移”这个动作不抄任何现成数据。实验做到崩溃-定位-跳转一气呵成时我会把关键参数记录成一张环境卡。我把典型问题整理成了表格方便你快速对照现象最可能原因处理建议EIP不是0x41414141输入长度不够或GS看调用栈、确认编译选项偏移算不准编译器额外padding用调试器量EBP与buffer差值地址写入被截断地址含0x00且用strcpy换memcpy或换无00地址跳转后二次崩溃win函数无合法返回地址接受崩溃观察是否打印标记字符串别人偏移和你不同编译/系统环境差异不做搬运工自己实测5. 最后分享一个习惯5.1 我的实验记录模板给初学者的第一个建议每次做漏洞实验前把实验参数记在一个简单的表里包含函数名、buffer大小、编译选项、实测偏移、目标地址、调试器算出结果。这个习惯帮我避开了大量重复试错。我的模板大致是源码版本、编译命令、加载基址、buffer首地址、EBP地址、偏移量、尝试的payload长度。这些信息看着琐碎但当你连续调试多个实验时它能直接告诉你环境之间差在哪。尤其是编译选项上次用VC编译、这次用GCC编译同一个漏洞程序偏移可能完全不同。刚开始学栈溢出时我也犯过“背答案”的毛病觉得记住偏移72就完事了。直到换了个环境同样代码崩出完全不同的EIP才明白所有数字都该现场量出来。这个教训应该能让后来者少走弯路。5.2 安全研究的边界必须自己划清楚这类栈溢出实验我从头到尾都建议在隔离虚拟机进行。你不需要对任何没有授权的真实系统发起调试和攻击测试也不需要去下载针对特定软件的攻击代码。学习目标是理解内存管理、理解防御机制、理解攻击原理而不是获得一套能打穿某个软件的武器。从我个人的实践体会来说栈溢出最迷人的地方不是“怎么打”而是通过一次崩溃看到程序内在的执行逻辑。当你看着EIP被一串精心构造的字符牵着走你会对“代码和数据没有绝对区别”这句话有非常具象的领悟。这种掌控感才是推动我继续研究漏洞分析的核心动力。如果你刚起步把这套实验认真做完再去挑战堆溢出、格式化字符串、ROP这些进阶话题路会顺很多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →